2026-10-06 15:03:16 +00:00
|
|
|
"""
|
|
|
|
|
BetterCurseForge — Core Patching Logic
|
|
|
|
|
=======================================
|
|
|
|
|
Direct ASAR modification + OWEASARSIG header hash synchronization.
|
|
|
|
|
Works on all versions of CurseForge (ow-electron).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import json
|
|
|
|
|
import struct
|
|
|
|
|
import shutil
|
|
|
|
|
import hashlib
|
|
|
|
|
import winreg
|
2026-10-06 16:49:12 +00:00
|
|
|
import time
|
2026-10-06 15:03:16 +00:00
|
|
|
import subprocess
|
2026-10-06 17:27:02 +00:00
|
|
|
import re
|
2026-10-06 15:03:16 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-10-06 16:49:12 +00:00
|
|
|
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
2026-10-06 15:03:16 +00:00
|
|
|
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
|
|
|
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
|
|
|
|
|
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
|
2026-10-06 16:49:12 +00:00
|
|
|
PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex"
|
2026-10-12 15:12:24 +00:00
|
|
|
SPLASH_MUTEX_NAME = "BetterCurseForgeSplashMutex"
|
2026-10-06 15:03:16 +00:00
|
|
|
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
|
|
|
|
|
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
|
|
|
|
|
PERMANENT_BCF = PERMANENT_DIR / "bettercf"
|
|
|
|
|
PERMANENT_VENDOR = PERMANENT_DIR / "vendor"
|
2026-10-08 19:18:55 +00:00
|
|
|
PERMANENT_ASSETS = PERMANENT_DIR / "assets"
|
2026-10-06 15:03:16 +00:00
|
|
|
|
2026-10-06 16:49:12 +00:00
|
|
|
class GlobalNamedMutex:
|
|
|
|
|
def __init__(self, name: str, timeout_ms: int = 5000):
|
|
|
|
|
self.name = name
|
|
|
|
|
self.timeout_ms = timeout_ms
|
|
|
|
|
self.handle = None
|
|
|
|
|
self.acquired = False
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
if os.name != "nt":
|
|
|
|
|
self.acquired = True
|
|
|
|
|
return self
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
|
|
|
|
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
|
|
|
|
|
kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
|
|
|
|
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint]
|
|
|
|
|
kernel32.ReleaseMutex.argtypes = [ctypes.c_void_p]
|
|
|
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
|
|
|
|
|
|
|
|
self.handle = kernel32.CreateMutexW(None, False, self.name)
|
|
|
|
|
if self.handle:
|
|
|
|
|
res = kernel32.WaitForSingleObject(self.handle, self.timeout_ms)
|
|
|
|
|
if res in (0, 0x80): # WAIT_OBJECT_0 or WAIT_ABANDONED
|
|
|
|
|
self.acquired = True
|
|
|
|
|
except Exception:
|
|
|
|
|
self.acquired = True
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
|
|
|
if os.name == "nt" and self.handle:
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
if self.acquired:
|
|
|
|
|
kernel32.ReleaseMutex(self.handle)
|
|
|
|
|
kernel32.CloseHandle(self.handle)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
def get_curseforge_dir() -> Path | None:
|
|
|
|
|
# 1. Registry lookup
|
|
|
|
|
try:
|
|
|
|
|
key = winreg.OpenKey(
|
|
|
|
|
winreg.HKEY_CURRENT_USER,
|
|
|
|
|
r"Software\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
|
|
|
)
|
|
|
|
|
i = 0
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
subkey_name = winreg.EnumKey(key, i)
|
|
|
|
|
i += 1
|
|
|
|
|
try:
|
|
|
|
|
subkey = winreg.OpenKey(key, subkey_name)
|
|
|
|
|
display_name, _ = winreg.QueryValueEx(subkey, "DisplayName")
|
|
|
|
|
if "CurseForge" in display_name:
|
|
|
|
|
uninstall_str, _ = winreg.QueryValueEx(subkey, "UninstallString")
|
|
|
|
|
path_str = uninstall_str.strip('"').split('" ')[0].strip('"')
|
|
|
|
|
install_dir = Path(path_str).parent
|
|
|
|
|
if install_dir.exists() and (install_dir / "CurseForge.exe").exists():
|
|
|
|
|
winreg.CloseKey(subkey)
|
|
|
|
|
winreg.CloseKey(key)
|
|
|
|
|
return install_dir
|
|
|
|
|
winreg.CloseKey(subkey)
|
|
|
|
|
except (FileNotFoundError, OSError):
|
|
|
|
|
pass
|
|
|
|
|
except OSError:
|
|
|
|
|
break
|
|
|
|
|
winreg.CloseKey(key)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# 2. LocalAppData fallback
|
|
|
|
|
appdata = os.environ.get("LOCALAPPDATA", "")
|
|
|
|
|
for p in [
|
|
|
|
|
Path(appdata) / "Programs" / "CurseForge Windows",
|
|
|
|
|
Path(appdata) / "CurseForge",
|
|
|
|
|
]:
|
|
|
|
|
if p.exists() and (p / "CurseForge.exe").exists():
|
|
|
|
|
return p
|
|
|
|
|
return None
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
def get_process_pids(image_name: str) -> list[int]:
|
|
|
|
|
"""
|
|
|
|
|
Ultra-fast process lookup via Win32 Toolhelp32Snapshot (<1ms, zero subprocess overhead).
|
|
|
|
|
"""
|
|
|
|
|
if os.name != "nt":
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
from ctypes import wintypes
|
|
|
|
|
TH32CS_SNAPPROCESS = 0x00000002
|
|
|
|
|
class PROCESSENTRY32(ctypes.Structure):
|
|
|
|
|
_fields_ = [
|
|
|
|
|
('dwSize', wintypes.DWORD),
|
|
|
|
|
('cntUsage', wintypes.DWORD),
|
|
|
|
|
('th32ProcessID', wintypes.DWORD),
|
|
|
|
|
('th32DefaultHeapID', ctypes.c_size_t),
|
|
|
|
|
('th32ModuleID', wintypes.DWORD),
|
|
|
|
|
('cntThreads', wintypes.DWORD),
|
|
|
|
|
('th32ParentProcessID', wintypes.DWORD),
|
|
|
|
|
('pcPriClassBase', ctypes.c_long),
|
|
|
|
|
('dwFlags', wintypes.DWORD),
|
|
|
|
|
('szExeFile', ctypes.c_char * 260)
|
|
|
|
|
]
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
h_snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
|
|
|
|
if h_snap == -1 or not h_snap:
|
|
|
|
|
return []
|
|
|
|
|
pe = PROCESSENTRY32()
|
|
|
|
|
pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
|
|
|
|
|
pids = []
|
|
|
|
|
target = image_name.lower()
|
|
|
|
|
if kernel32.Process32First(h_snap, ctypes.byref(pe)):
|
|
|
|
|
while True:
|
|
|
|
|
name = pe.szExeFile.decode('mbcs', errors='ignore').lower()
|
|
|
|
|
if name == target:
|
|
|
|
|
pids.append(pe.th32ProcessID)
|
|
|
|
|
if not kernel32.Process32Next(h_snap, ctypes.byref(pe)):
|
|
|
|
|
break
|
|
|
|
|
kernel32.CloseHandle(h_snap)
|
|
|
|
|
return pids
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def kill_process_by_name(image_name: str) -> int:
|
|
|
|
|
"""
|
|
|
|
|
Silently and immediately terminates all matching processes via TerminateProcess (<1ms).
|
|
|
|
|
"""
|
|
|
|
|
if os.name != "nt":
|
|
|
|
|
return 0
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
PROCESS_TERMINATE = 0x0001
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
pids = get_process_pids(image_name)
|
|
|
|
|
killed = 0
|
|
|
|
|
for pid in pids:
|
|
|
|
|
h_proc = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
|
|
|
|
if h_proc:
|
|
|
|
|
if kernel32.TerminateProcess(h_proc, 1):
|
|
|
|
|
killed += 1
|
|
|
|
|
kernel32.CloseHandle(h_proc)
|
|
|
|
|
return killed
|
|
|
|
|
except Exception:
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
def kill_curseforge():
|
2026-10-12 15:12:24 +00:00
|
|
|
kill_process_by_name("curseforge.exe")
|
2026-10-06 15:03:16 +00:00
|
|
|
try:
|
2026-10-06 16:49:12 +00:00
|
|
|
subprocess.run(
|
|
|
|
|
["taskkill", "/f", "/im", "CurseForge.exe"],
|
2026-10-12 15:12:24 +00:00
|
|
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
|
2026-10-06 16:49:12 +00:00
|
|
|
)
|
2026-10-12 15:12:24 +00:00
|
|
|
time.sleep(0.3)
|
2026-10-06 15:03:16 +00:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def calc_asar_header_hash(asar_path: Path) -> str:
|
|
|
|
|
with open(asar_path, "rb") as f:
|
|
|
|
|
f.seek(12)
|
|
|
|
|
u4 = struct.unpack("<I", f.read(4))[0]
|
|
|
|
|
header_bytes = f.read(u4)
|
|
|
|
|
return hashlib.sha256(header_bytes).hexdigest()
|
|
|
|
|
|
|
|
|
|
def read_exe_hash(exe_path: Path) -> str | None:
|
|
|
|
|
try:
|
|
|
|
|
with open(exe_path, "rb") as f:
|
|
|
|
|
data = f.read()
|
|
|
|
|
sig = "OWEASARSIG".encode("utf-16le")
|
|
|
|
|
idx = data.rfind(sig)
|
|
|
|
|
if idx == -1:
|
|
|
|
|
idx = data.rfind(b"OWEASARSIG")
|
|
|
|
|
if idx == -1:
|
|
|
|
|
return None
|
|
|
|
|
val_idx = data.find(b'"value":"', idx)
|
|
|
|
|
if val_idx == -1:
|
|
|
|
|
return None
|
|
|
|
|
hash_start = val_idx + 9
|
|
|
|
|
return data[hash_start:hash_start+64].decode("ascii", errors="replace")
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
|
2026-10-06 16:49:12 +00:00
|
|
|
"""
|
|
|
|
|
Safely updates the 64-character ASAR header SHA256 hash inside CurseForge.exe
|
|
|
|
|
in-place with automatic retry handling for temporary file locks.
|
|
|
|
|
"""
|
|
|
|
|
if len(new_hash) != 64:
|
|
|
|
|
raise ValueError(f"Invalid ASAR hash length: {len(new_hash)}")
|
|
|
|
|
|
|
|
|
|
last_err = None
|
|
|
|
|
for attempt in range(12):
|
|
|
|
|
try:
|
|
|
|
|
with open(exe_path, "r+b") as f:
|
|
|
|
|
data = f.read()
|
|
|
|
|
sig = "OWEASARSIG".encode("utf-16le")
|
|
|
|
|
idx = data.rfind(sig)
|
|
|
|
|
if idx == -1:
|
|
|
|
|
idx = data.rfind(b"OWEASARSIG")
|
|
|
|
|
if idx == -1:
|
|
|
|
|
raise ValueError("OWEASARSIG signature block not found in CurseForge.exe")
|
|
|
|
|
val_idx = data.find(b'"value":"', idx)
|
|
|
|
|
if val_idx == -1:
|
|
|
|
|
raise ValueError('"value":" field not found in OWEASARSIG resource')
|
|
|
|
|
hash_start = val_idx + 9
|
|
|
|
|
old_hash = data[hash_start:hash_start+64].decode("ascii", errors="replace")
|
|
|
|
|
if old_hash == new_hash:
|
|
|
|
|
return True # Already up to date!
|
|
|
|
|
f.seek(hash_start)
|
|
|
|
|
f.write(new_hash.encode("ascii"))
|
|
|
|
|
f.flush()
|
|
|
|
|
return True
|
|
|
|
|
except PermissionError as e:
|
|
|
|
|
last_err = e
|
|
|
|
|
kill_curseforge()
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
last_err = e
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
|
|
|
|
if last_err:
|
|
|
|
|
raise last_err
|
|
|
|
|
return False
|
2026-10-06 15:03:16 +00:00
|
|
|
|
|
|
|
|
def get_user_data_dir() -> Path:
|
|
|
|
|
appdata = Path(os.environ.get("APPDATA", ""))
|
|
|
|
|
bcf_dir = appdata / "CurseForge" / "BetterCurseForge"
|
|
|
|
|
(bcf_dir / "themes").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
(bcf_dir / "plugins").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
return bcf_dir
|
|
|
|
|
|
|
|
|
|
def ensure_sample_content(bcf_src: Path):
|
|
|
|
|
user_dir = get_user_data_dir()
|
|
|
|
|
themes_src = bcf_src / "themes"
|
|
|
|
|
plugins_src = bcf_src / "plugins"
|
|
|
|
|
|
|
|
|
|
if themes_src.exists():
|
|
|
|
|
for item in themes_src.iterdir():
|
|
|
|
|
dest = user_dir / "themes" / item.name
|
|
|
|
|
if item.is_file():
|
|
|
|
|
if not dest.exists() or item.name.startswith("purple-night"):
|
|
|
|
|
shutil.copy2(item, dest)
|
|
|
|
|
elif item.is_dir() and not dest.exists():
|
|
|
|
|
shutil.copytree(item, dest)
|
|
|
|
|
|
|
|
|
|
if plugins_src.exists():
|
|
|
|
|
for item in plugins_src.iterdir():
|
|
|
|
|
dest = user_dir / "plugins" / item.name
|
|
|
|
|
if not dest.exists():
|
|
|
|
|
if item.is_file():
|
|
|
|
|
shutil.copy2(item, dest)
|
|
|
|
|
elif item.is_dir():
|
|
|
|
|
shutil.copytree(item, dest)
|
|
|
|
|
|
|
|
|
|
def find_bettercf_source() -> Path | None:
|
|
|
|
|
candidates = [
|
|
|
|
|
Path(getattr(sys, "_MEIPASS", "")) / "bettercf" if hasattr(sys, "_MEIPASS") else None,
|
|
|
|
|
Path(sys.executable).parent / "bettercf" if getattr(sys, "frozen", False) else None,
|
|
|
|
|
PERMANENT_BCF,
|
|
|
|
|
Path(__file__).parent / "bettercf",
|
|
|
|
|
Path(__file__).parent.parent / "bettercf",
|
|
|
|
|
Path.cwd() / "bettercf",
|
|
|
|
|
]
|
|
|
|
|
for c in candidates:
|
|
|
|
|
if c and c.exists() and c.is_dir():
|
|
|
|
|
return c
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def find_vendor_dir() -> Path | None:
|
|
|
|
|
candidates = [
|
|
|
|
|
Path(getattr(sys, "_MEIPASS", "")) / "vendor" if hasattr(sys, "_MEIPASS") else None,
|
|
|
|
|
Path(sys.executable).parent / "vendor" if getattr(sys, "frozen", False) else None,
|
|
|
|
|
PERMANENT_VENDOR,
|
|
|
|
|
Path(__file__).parent / "vendor",
|
|
|
|
|
Path(__file__).parent.parent / "patcher" / "vendor",
|
|
|
|
|
Path.cwd() / "patcher" / "vendor",
|
|
|
|
|
Path.cwd() / "vendor",
|
|
|
|
|
]
|
|
|
|
|
for c in candidates:
|
|
|
|
|
if c and c.exists() and (c / "asar" / "node_modules" / "@electron" / "asar" / "bin" / "asar.mjs").exists():
|
|
|
|
|
return c
|
|
|
|
|
return None
|
|
|
|
|
|
2026-10-08 19:18:55 +00:00
|
|
|
def find_assets_dir() -> Path | None:
|
|
|
|
|
candidates = [
|
|
|
|
|
Path(getattr(sys, "_MEIPASS", "")) / "assets" if hasattr(sys, "_MEIPASS") else None,
|
|
|
|
|
Path(sys.executable).parent / "assets" if getattr(sys, "frozen", False) else None,
|
|
|
|
|
PERMANENT_ASSETS,
|
|
|
|
|
Path(__file__).parent / "assets",
|
|
|
|
|
Path(__file__).parent.parent / "assets",
|
|
|
|
|
Path.cwd() / "assets",
|
|
|
|
|
]
|
|
|
|
|
for c in candidates:
|
|
|
|
|
if c and c.exists() and c.is_dir():
|
|
|
|
|
return c
|
|
|
|
|
return None
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
def run_asar_tool(args: list[str], cf_dir: Path | None = None) -> tuple[bool, str]:
|
|
|
|
|
"""
|
|
|
|
|
Executes asar pack/extract offline using bundled @electron/asar with either
|
|
|
|
|
CurseForge's internal Node v24 runtime or system node.
|
|
|
|
|
Falls back to npx only if offline runners fail.
|
|
|
|
|
"""
|
|
|
|
|
vendor_dir = find_vendor_dir()
|
|
|
|
|
if vendor_dir:
|
|
|
|
|
asar_mjs = vendor_dir / "asar" / "node_modules" / "@electron" / "asar" / "bin" / "asar.mjs"
|
|
|
|
|
node_modules = vendor_dir / "asar" / "node_modules"
|
|
|
|
|
|
|
|
|
|
runners = []
|
|
|
|
|
# Runner 1: CurseForge.exe itself (built-in Node v24, 100% offline & compatible)
|
|
|
|
|
if cf_dir and (cf_dir / "CurseForge.exe").exists():
|
|
|
|
|
runners.append((str(cf_dir / "CurseForge.exe"), True))
|
|
|
|
|
# Runner 2: System Node.js
|
|
|
|
|
sys_node = shutil.which("node")
|
|
|
|
|
if sys_node:
|
|
|
|
|
runners.append((sys_node, False))
|
|
|
|
|
# Runner 3: Vanilla backup of CurseForge.exe
|
|
|
|
|
if cf_dir and (cf_dir / "CurseForge.exe.vanilla").exists():
|
|
|
|
|
runners.append((str(cf_dir / "CurseForge.exe.vanilla"), True))
|
|
|
|
|
|
|
|
|
|
for runner_bin, is_electron in runners:
|
|
|
|
|
try:
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
if is_electron:
|
|
|
|
|
env["ELECTRON_RUN_AS_NODE"] = "1"
|
|
|
|
|
env["NODE_PATH"] = str(node_modules)
|
|
|
|
|
cmd = [runner_bin, str(asar_mjs)] + args
|
|
|
|
|
creation_flags = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
env=env,
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
creationflags=creation_flags,
|
|
|
|
|
timeout=45
|
|
|
|
|
)
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
return (True, r.stdout.strip())
|
|
|
|
|
else:
|
|
|
|
|
err_msg = r.stderr.strip() or r.stdout.strip()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
err_msg = str(e)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Fallback to npx if vendor runner was unavailable
|
|
|
|
|
try:
|
|
|
|
|
cmd_npx = ["npx", "--yes", "@electron/asar"] + args
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
cmd_npx,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
shell=True,
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
2026-10-06 16:49:12 +00:00
|
|
|
timeout=60,
|
|
|
|
|
creationflags=CREATE_NO_WINDOW
|
2026-10-06 15:03:16 +00:00
|
|
|
)
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
return (True, r.stdout.strip())
|
|
|
|
|
return (False, r.stderr.strip() or r.stdout.strip())
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return (False, str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_asar_patched(asar_path: Path) -> bool:
|
|
|
|
|
if not asar_path.exists():
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
with open(asar_path, "rb") as f:
|
|
|
|
|
f.seek(12)
|
|
|
|
|
u4 = struct.unpack("<I", f.read(4))[0]
|
|
|
|
|
header = f.read(u4).decode("utf-8", errors="replace")
|
|
|
|
|
return "bettercf" in header
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def is_curseforge_patched(cf_dir: Path) -> bool:
|
|
|
|
|
asar_path = cf_dir / "resources" / "app.asar"
|
|
|
|
|
return is_asar_patched(asar_path)
|
|
|
|
|
|
|
|
|
|
def patch_curseforge(log_callback=print) -> bool:
|
2026-10-06 16:49:12 +00:00
|
|
|
patch_lock = GlobalNamedMutex(PATCH_MUTEX_NAME, timeout_ms=8000)
|
|
|
|
|
patch_lock.__enter__()
|
|
|
|
|
if not patch_lock.acquired:
|
|
|
|
|
log_callback("[!] Another patch operation is already in progress. Skipping.")
|
|
|
|
|
return False
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
cf_dir = get_curseforge_dir()
|
|
|
|
|
if not cf_dir:
|
|
|
|
|
log_callback("CurseForge installation directory not found!")
|
2026-10-06 16:49:12 +00:00
|
|
|
patch_lock.__exit__(None, None, None)
|
2026-10-06 15:03:16 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
resources_dir = cf_dir / "resources"
|
|
|
|
|
asar_path = resources_dir / "app.asar"
|
|
|
|
|
exe_path = cf_dir / "CurseForge.exe"
|
|
|
|
|
|
|
|
|
|
if not asar_path.exists() or not exe_path.exists():
|
|
|
|
|
log_callback("CurseForge resources/app.asar or CurseForge.exe missing!")
|
2026-10-06 16:49:12 +00:00
|
|
|
patch_lock.__exit__(None, None, None)
|
2026-10-06 15:03:16 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
asar_bak = resources_dir / "app.asar.vanilla"
|
|
|
|
|
exe_bak = cf_dir / "CurseForge.exe.vanilla"
|
|
|
|
|
|
|
|
|
|
log_callback(f"Found CurseForge at: {cf_dir}")
|
|
|
|
|
kill_curseforge()
|
|
|
|
|
|
|
|
|
|
# If CurseForge just updated (clean vanilla files from Overwolf), refresh vanilla backups
|
|
|
|
|
try:
|
|
|
|
|
current_exe_sig = read_exe_hash(exe_path)
|
|
|
|
|
current_asar_hash = calc_asar_header_hash(asar_path)
|
|
|
|
|
if current_exe_sig == current_asar_hash and not is_curseforge_patched(cf_dir):
|
|
|
|
|
# Clean Overwolf update detected! Refresh vanilla backups to match new version
|
|
|
|
|
shutil.copy2(asar_path, asar_bak)
|
|
|
|
|
shutil.copy2(exe_path, exe_bak)
|
|
|
|
|
log_callback(f"[+] Updated vanilla backup to latest CurseForge version")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if not asar_bak.exists() or is_asar_patched(asar_bak):
|
|
|
|
|
if not is_asar_patched(asar_path):
|
|
|
|
|
log_callback("Creating vanilla backup of app.asar...")
|
|
|
|
|
shutil.copy2(asar_path, asar_bak)
|
|
|
|
|
if not exe_bak.exists():
|
|
|
|
|
log_callback("Creating vanilla backup of CurseForge.exe...")
|
|
|
|
|
shutil.copy2(exe_path, exe_bak)
|
|
|
|
|
|
|
|
|
|
# Clean old unused resources/app folder if present
|
|
|
|
|
old_app = resources_dir / "app"
|
|
|
|
|
if old_app.exists():
|
|
|
|
|
try:
|
|
|
|
|
shutil.rmtree(old_app)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Extract asar
|
|
|
|
|
temp_extract = resources_dir / "bettercf_extract_temp"
|
|
|
|
|
if temp_extract.exists():
|
|
|
|
|
shutil.rmtree(temp_extract, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
# Always extract asar_path directly (so @electron/asar finds app.asar.unpacked)
|
|
|
|
|
source_asar = asar_path
|
|
|
|
|
|
|
|
|
|
log_callback(f"Extracting {'vanilla backup' if source_asar == asar_bak else 'app.asar'}...")
|
|
|
|
|
ok, err = run_asar_tool(["extract", str(source_asar), str(temp_extract)], cf_dir=cf_dir)
|
|
|
|
|
if not ok:
|
|
|
|
|
log_callback(f"Failed to extract asar: {err}")
|
2026-10-06 16:49:12 +00:00
|
|
|
patch_lock.__exit__(None, None, None)
|
2026-10-06 15:03:16 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
try:
|
2026-10-06 17:27:02 +00:00
|
|
|
# 1. Themes Patch in desktop.js (Ads are fully handled by desktop.css & network blocking)
|
2026-10-06 15:03:16 +00:00
|
|
|
desktop_js = temp_extract / "dist" / "desktop" / "desktop.js"
|
|
|
|
|
if desktop_js.exists():
|
|
|
|
|
content = desktop_js.read_text(encoding="utf-8")
|
2026-10-06 17:27:02 +00:00
|
|
|
# Restore clean eV component if previously patched (in modern CurseForge, eV is the modpack card author component, not ads)
|
|
|
|
|
if "/*BCF_AD_OFF*/eV=e=>{return null;let" in content:
|
|
|
|
|
content = content.replace("/*BCF_AD_OFF*/eV=e=>{return null;let", "eV=e=>{let")
|
|
|
|
|
log_callback("[+] Restored intact modpack card component (eV)")
|
2026-10-06 15:03:16 +00:00
|
|
|
while "let __orig_eV=e=>null;" in content:
|
|
|
|
|
content = content.replace("let __orig_eV=e=>null;", "")
|
|
|
|
|
if "eV=e=>null;let __orig_eV=e=>{" in content:
|
|
|
|
|
content = content.replace("eV=e=>null;let __orig_eV=e=>{", "eV=e=>{")
|
|
|
|
|
|
2026-10-06 17:27:02 +00:00
|
|
|
# Unlock all native premium themes (Settings & Popup routes) across versions
|
|
|
|
|
content, c1 = re.subn(
|
|
|
|
|
r'([a-zA-Z0-9_$]+)=e=>!!e\.forceUnlock\|\|!e\.isPremium\|\|[^,;]+',
|
|
|
|
|
r'\1=e=>!0',
|
|
|
|
|
content
|
|
|
|
|
)
|
|
|
|
|
if c1 > 0:
|
|
|
|
|
log_callback(f"[+] Unlocked native premium themes ({c1} route checks)")
|
|
|
|
|
|
|
|
|
|
# Unlock premium theme state validation
|
|
|
|
|
content, c2 = re.subn(
|
|
|
|
|
r'return!r\?\.forceUnlock&&r\?\.isPremium&&!t\?[a-zA-Z0-9_$.]+:n',
|
|
|
|
|
r'return n',
|
|
|
|
|
content
|
|
|
|
|
)
|
|
|
|
|
if c2 > 0:
|
2026-10-06 15:03:16 +00:00
|
|
|
log_callback("[+] Unlocked premium theme state validation")
|
2026-10-06 17:27:02 +00:00
|
|
|
|
|
|
|
|
# Unlock subscription plan status for themes
|
|
|
|
|
content, c3 = re.subn(
|
|
|
|
|
r'function ([a-zA-Z0-9_$]+)\(e\)\{return e\.subscription\.plan\?\.active\}',
|
|
|
|
|
r'function \1(e){return !0}',
|
|
|
|
|
content
|
|
|
|
|
)
|
|
|
|
|
if c3 > 0:
|
|
|
|
|
log_callback(f"[+] Unlocked subscription plan status for themes ({c3} checks)")
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
desktop_js.write_text(content, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
# 2. Zero-Ad & Settings Themes CSS in desktop.css
|
|
|
|
|
desktop_css = temp_extract / "dist" / "desktop" / "desktop.css"
|
|
|
|
|
if desktop_css.exists():
|
|
|
|
|
css_rules = """
|
|
|
|
|
/* ═══ BetterCurseForge Zero-Ad Rules ═══ */
|
|
|
|
|
.curseforge-ad,
|
|
|
|
|
.ad-inner,
|
|
|
|
|
.overwolf-ad,
|
|
|
|
|
.swap-ads-location,
|
|
|
|
|
owadview,
|
|
|
|
|
[class*="-skin-aside"],
|
|
|
|
|
.promo-banner,
|
|
|
|
|
.subscribe-link,
|
|
|
|
|
.subscribe-link-small-resolution,
|
|
|
|
|
.subscribe-link-large-resolution,
|
|
|
|
|
[data-ad],
|
|
|
|
|
[data-ad-slot] {
|
|
|
|
|
display: none !important;
|
|
|
|
|
width: 0 !important;
|
|
|
|
|
height: 0 !important;
|
|
|
|
|
min-width: 0 !important;
|
|
|
|
|
max-width: 0 !important;
|
|
|
|
|
margin: 0 !important;
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
position: absolute !important;
|
|
|
|
|
visibility: hidden !important;
|
|
|
|
|
pointer-events: none !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ═══ BetterCurseForge Settings Themes UI ═══ */
|
|
|
|
|
#bcf-custom-themes-section {
|
|
|
|
|
margin-top: 12px !important;
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
.bcf-themes-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
.bcf-heading {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
line-height: 24px;
|
|
|
|
|
color: var(--color-text-title, currentColor);
|
|
|
|
|
}
|
|
|
|
|
.bcf-heading > span:first-child {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.bcf-tag {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
color: #a78bfa;
|
|
|
|
|
background: rgba(139, 92, 246, 0.12);
|
|
|
|
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
|
|
|
padding: 3px 8px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
letter-spacing: 0.3px;
|
|
|
|
|
}
|
|
|
|
|
.bcf-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.bcf-btn {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
height: 28px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
color: var(--color-text-primary, currentColor);
|
|
|
|
|
background: var(--surface-on-surface, rgba(255, 255, 255, 0.08));
|
|
|
|
|
border: 1px solid var(--border-tertiery, rgba(255, 255, 255, 0.15));
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
.bcf-btn svg {
|
|
|
|
|
display: block;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
width: 13px;
|
|
|
|
|
height: 13px;
|
|
|
|
|
}
|
|
|
|
|
.bcf-btn:hover {
|
|
|
|
|
background: var(--surface-on-surface-hover, rgba(255, 255, 255, 0.16));
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
color: var(--color-text-title-hover, #ffffff);
|
|
|
|
|
}
|
|
|
|
|
.bcf-btn:active {
|
|
|
|
|
transform: scale(0.97);
|
|
|
|
|
}
|
|
|
|
|
.bcf-theme-thumb {
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
}
|
|
|
|
|
.bcf-theme-thumb .thumb-icon svg {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
.bcf-theme-thumb.is-active .sprite-icon {
|
|
|
|
|
opacity: 1 !important;
|
|
|
|
|
color: #ffffff !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Mutual exclusivity: hide checkmark on native theme cards when a client theme is active */
|
2026-10-08 18:31:33 +00:00
|
|
|
html.bcf-client-theme-selected .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .sprite-icon,
|
|
|
|
|
html.bcf-native-preview-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .sprite-icon,
|
|
|
|
|
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .sprite-icon {
|
2026-10-06 15:03:16 +00:00
|
|
|
opacity: 0 !important;
|
|
|
|
|
}
|
2026-10-08 18:31:33 +00:00
|
|
|
html.bcf-client-theme-selected .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .thumb-icon,
|
|
|
|
|
html.bcf-native-preview-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .thumb-icon,
|
|
|
|
|
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) .thumb-icon {
|
2026-10-06 15:03:16 +00:00
|
|
|
background-color: transparent !important;
|
|
|
|
|
}
|
2026-10-08 18:31:33 +00:00
|
|
|
html.bcf-client-theme-selected .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover),
|
|
|
|
|
html.bcf-native-preview-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover),
|
|
|
|
|
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active:not(:hover) {
|
2026-10-06 15:03:16 +00:00
|
|
|
outline: none !important;
|
|
|
|
|
border-color: transparent !important;
|
|
|
|
|
box-shadow: none !important;
|
|
|
|
|
}
|
|
|
|
|
/* Neutralize native skin styles and title bar */
|
|
|
|
|
html.bcf-custom-theme-active .title-bar,
|
|
|
|
|
html.bcf-custom-theme-active [class*="title-bar"],
|
|
|
|
|
html.bcf-custom-theme-active [class*="TitleBar"] {
|
|
|
|
|
background: #0d0818 !important;
|
|
|
|
|
--title-bar-background: #0d0818 !important;
|
|
|
|
|
}
|
|
|
|
|
html.bcf-custom-theme-active .pixelforge-skin,
|
|
|
|
|
html.bcf-custom-theme-active .lifeforge-skin,
|
|
|
|
|
html.bcf-custom-theme-active .fantasyforge-skin,
|
|
|
|
|
html.bcf-custom-theme-active .halloween-skin,
|
|
|
|
|
html.bcf-custom-theme-active .cf-light,
|
|
|
|
|
html.bcf-custom-theme-active .cf-light-colorblind {
|
|
|
|
|
--title-bar-background: #0d0818 !important;
|
|
|
|
|
--surface-brand-brand: #8b5cf6 !important;
|
|
|
|
|
--surface-brand-brand-hover: #9d74f7 !important;
|
|
|
|
|
--accent-font: var(--body-font, "Lato", sans-serif) !important;
|
|
|
|
|
background-color: transparent !important;
|
|
|
|
|
}
|
|
|
|
|
html.bcf-custom-theme-active .pixelforge-skin :not(.highlighted-project-container) .card:after {
|
|
|
|
|
display: none !important;
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
css_text = desktop_css.read_text(encoding="utf-8")
|
2026-10-08 18:31:33 +00:00
|
|
|
if "/* ═══ BetterCurseForge Zero-Ad Rules ═══ */" in css_text:
|
|
|
|
|
idx = css_text.find("/* ═══ BetterCurseForge Zero-Ad Rules ═══ */")
|
|
|
|
|
css_text = css_text[:idx].rstrip()
|
|
|
|
|
desktop_css.write_text(css_text + "\n" + css_rules, encoding="utf-8")
|
|
|
|
|
log_callback("[+] Injected zero-ad and theme CSS rules into desktop.css")
|
2026-10-06 15:03:16 +00:00
|
|
|
|
|
|
|
|
# 3. Copy BetterCF runtime into asar
|
|
|
|
|
bcf_src = find_bettercf_source()
|
|
|
|
|
if bcf_src:
|
|
|
|
|
ensure_sample_content(bcf_src)
|
|
|
|
|
bcf_dest = temp_extract / "dist" / "bettercf"
|
|
|
|
|
if bcf_dest.exists():
|
|
|
|
|
shutil.rmtree(bcf_dest)
|
|
|
|
|
shutil.copytree(bcf_src, bcf_dest)
|
|
|
|
|
log_callback("[+] Bundled BetterCurseForge runtime into asar")
|
|
|
|
|
|
|
|
|
|
# 4. Inject main process hooks in background.js
|
|
|
|
|
bg_js = temp_extract / "dist" / "background" / "background.js"
|
|
|
|
|
if bg_js.exists():
|
|
|
|
|
bg_content = bg_js.read_text(encoding="utf-8")
|
|
|
|
|
if "bettercf/core/main-process.js" not in bg_content:
|
|
|
|
|
bg_inject = "import '../bettercf/core/main-process.js';\n"
|
|
|
|
|
bg_js.write_text(bg_inject + bg_content, encoding="utf-8")
|
|
|
|
|
log_callback("[+] Injected main-process hooks into background.js")
|
|
|
|
|
|
|
|
|
|
# 5. Inject renderer script into desktop.html
|
|
|
|
|
desktop_html = temp_extract / "dist" / "desktop" / "desktop.html"
|
|
|
|
|
if desktop_html.exists():
|
|
|
|
|
html_content = desktop_html.read_text(encoding="utf-8")
|
|
|
|
|
if "bettercf/core/renderer.js" not in html_content:
|
|
|
|
|
bcf_script = '<script src="../bettercf/core/renderer.js"></script>'
|
|
|
|
|
html_content = html_content.replace("</head>", f"{bcf_script}</head>")
|
|
|
|
|
desktop_html.write_text(html_content, encoding="utf-8")
|
|
|
|
|
log_callback("[+] Injected renderer script into desktop.html")
|
|
|
|
|
|
|
|
|
|
# 6. Inject preload hook in preload.js
|
|
|
|
|
preload_js = temp_extract / "dist" / "preload" / "preload.js"
|
|
|
|
|
if preload_js.exists():
|
|
|
|
|
preload_content = preload_js.read_text(encoding="utf-8")
|
|
|
|
|
if "__BCF_IPC__" not in preload_content:
|
|
|
|
|
preload_hook = """
|
|
|
|
|
// ── BetterCurseForge Preload Hook ──
|
|
|
|
|
try {
|
|
|
|
|
const { ipcRenderer } = require('electron');
|
|
|
|
|
window.__BCF_IPC__ = {
|
|
|
|
|
invoke: (...args) => ipcRenderer.invoke(...args),
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
"""
|
|
|
|
|
preload_js.write_text(preload_content + "\n" + preload_hook, encoding="utf-8")
|
|
|
|
|
log_callback("[+] Injected renderer loader into preload.js")
|
|
|
|
|
|
|
|
|
|
# 6. Repack ASAR
|
|
|
|
|
log_callback("Repacking app.asar...")
|
|
|
|
|
ok, err = run_asar_tool(["pack", str(temp_extract), str(asar_path)], cf_dir=cf_dir)
|
|
|
|
|
if not ok:
|
|
|
|
|
log_callback(f"Failed to pack asar: {err}")
|
|
|
|
|
return False
|
|
|
|
|
|
2026-10-06 16:49:12 +00:00
|
|
|
# 7. Compute new hash and update CurseForge.exe directly in-place
|
2026-10-06 15:03:16 +00:00
|
|
|
new_hash = calc_asar_header_hash(asar_path)
|
|
|
|
|
log_callback(f"New ASAR header hash: {new_hash}")
|
2026-10-06 16:49:12 +00:00
|
|
|
if not exe_path.exists() and exe_bak.exists():
|
|
|
|
|
shutil.copy2(exe_bak, exe_path)
|
2026-10-06 15:03:16 +00:00
|
|
|
update_exe_hash(exe_path, new_hash)
|
|
|
|
|
log_callback("[+] Updated CurseForge.exe integrity signature")
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
if temp_extract.exists():
|
|
|
|
|
shutil.rmtree(temp_extract, ignore_errors=True)
|
2026-10-06 16:49:12 +00:00
|
|
|
patch_lock.__exit__(None, None, None)
|
2026-10-06 15:03:16 +00:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
ensure_permanent_installation()
|
|
|
|
|
set_autostart_watcher(True)
|
|
|
|
|
start_background_watcher()
|
|
|
|
|
log_callback("[+] Auto-repatch watcher active and persistent across updates.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log_callback(f"[!] Note: Could not auto-start watcher: {e}")
|
|
|
|
|
|
|
|
|
|
log_callback("[*] CurseForge successfully patched! Ads removed, themes & plugins enabled.")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def unpatch_curseforge(log_callback=print) -> bool:
|
|
|
|
|
cf_dir = get_curseforge_dir()
|
|
|
|
|
if not cf_dir:
|
|
|
|
|
log_callback("CurseForge not found.")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
kill_curseforge()
|
|
|
|
|
resources_dir = cf_dir / "resources"
|
|
|
|
|
asar_path = resources_dir / "app.asar"
|
|
|
|
|
exe_path = cf_dir / "CurseForge.exe"
|
|
|
|
|
asar_bak = resources_dir / "app.asar.vanilla"
|
|
|
|
|
exe_bak = cf_dir / "CurseForge.exe.vanilla"
|
|
|
|
|
|
|
|
|
|
restored = False
|
|
|
|
|
if asar_bak.exists():
|
|
|
|
|
shutil.copy2(asar_bak, asar_path)
|
|
|
|
|
log_callback("[+] Restored vanilla app.asar")
|
|
|
|
|
restored = True
|
|
|
|
|
if exe_bak.exists():
|
|
|
|
|
shutil.copy2(exe_bak, exe_path)
|
|
|
|
|
log_callback("[+] Restored vanilla CurseForge.exe")
|
|
|
|
|
restored = True
|
|
|
|
|
|
|
|
|
|
old_app = resources_dir / "app"
|
|
|
|
|
if old_app.exists():
|
|
|
|
|
shutil.rmtree(old_app, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
set_autostart_watcher(False)
|
|
|
|
|
stop_background_watcher()
|
|
|
|
|
log_callback("[-] Auto-repatch watcher deactivated.")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if restored:
|
|
|
|
|
log_callback("CurseForge successfully returned to vanilla.")
|
|
|
|
|
else:
|
|
|
|
|
log_callback("Vanilla backups not found. Reinstall CurseForge to restore completely.")
|
|
|
|
|
return restored
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# AUTO-REPATCH WATCHER & PERSISTENCE ENGINE
|
|
|
|
|
def is_curseforge_running() -> bool:
|
2026-10-12 15:12:24 +00:00
|
|
|
if os.name == "nt":
|
|
|
|
|
return len(get_process_pids("curseforge.exe")) > 0
|
2026-10-06 15:03:16 +00:00
|
|
|
try:
|
|
|
|
|
out = subprocess.check_output(
|
|
|
|
|
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
|
2026-10-06 16:49:12 +00:00
|
|
|
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
2026-10-06 15:03:16 +00:00
|
|
|
)
|
|
|
|
|
return "CurseForge.exe" in out
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def is_updater_running() -> bool:
|
2026-10-13 17:33:59 +00:00
|
|
|
"""
|
|
|
|
|
Checks if a CurseForge or Overwolf updater/installer process is currently running.
|
|
|
|
|
Strictly scoped to avoid false positives from unrelated apps (Discord, Logitech, etc.).
|
|
|
|
|
"""
|
2026-10-12 15:12:24 +00:00
|
|
|
if os.name == "nt":
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
from ctypes import wintypes
|
|
|
|
|
TH32CS_SNAPPROCESS = 0x00000002
|
|
|
|
|
class PROCESSENTRY32(ctypes.Structure):
|
|
|
|
|
_fields_ = [
|
|
|
|
|
('dwSize', wintypes.DWORD),
|
|
|
|
|
('cntUsage', wintypes.DWORD),
|
|
|
|
|
('th32ProcessID', wintypes.DWORD),
|
|
|
|
|
('th32DefaultHeapID', ctypes.c_size_t),
|
|
|
|
|
('th32ModuleID', wintypes.DWORD),
|
|
|
|
|
('cntThreads', wintypes.DWORD),
|
|
|
|
|
('th32ParentProcessID', wintypes.DWORD),
|
|
|
|
|
('pcPriClassBase', ctypes.c_long),
|
|
|
|
|
('dwFlags', wintypes.DWORD),
|
|
|
|
|
('szExeFile', ctypes.c_char * 260)
|
|
|
|
|
]
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
h_snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
|
|
|
|
if h_snap != -1 and h_snap:
|
|
|
|
|
pe = PROCESSENTRY32()
|
|
|
|
|
pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
|
|
|
|
|
found = False
|
|
|
|
|
if kernel32.Process32First(h_snap, ctypes.byref(pe)):
|
|
|
|
|
while True:
|
|
|
|
|
name = pe.szExeFile.decode('mbcs', errors='ignore').lower()
|
2026-10-13 17:33:59 +00:00
|
|
|
# Strictly match CurseForge or Overwolf installer/updater processes
|
|
|
|
|
if ('curseforge' in name and any(k in name for k in ('update', 'setup', 'install'))) or \
|
|
|
|
|
('overwolf' in name and any(k in name for k in ('installer', 'updater', 'setup'))):
|
2026-10-12 15:12:24 +00:00
|
|
|
found = True
|
|
|
|
|
break
|
|
|
|
|
if not kernel32.Process32Next(h_snap, ctypes.byref(pe)):
|
|
|
|
|
break
|
|
|
|
|
kernel32.CloseHandle(h_snap)
|
|
|
|
|
if found:
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
return False
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
def is_splash_running() -> bool:
|
|
|
|
|
if os.name != "nt":
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
import ctypes
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
kernel32.OpenMutexW.restype = ctypes.c_void_p
|
|
|
|
|
kernel32.OpenMutexW.argtypes = [ctypes.c_uint, ctypes.c_bool, ctypes.c_wchar_p]
|
|
|
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
|
|
|
SYNCHRONIZE = 0x00100000
|
|
|
|
|
h = kernel32.OpenMutexW(SYNCHRONIZE, False, SPLASH_MUTEX_NAME)
|
|
|
|
|
if h:
|
|
|
|
|
kernel32.CloseHandle(h)
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def launch_splash_updater(pid_to_wait: int | None = None) -> bool:
|
|
|
|
|
if is_splash_running():
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
perm_exe = ensure_permanent_installation()
|
|
|
|
|
args = ["--repatch-splash"]
|
|
|
|
|
if pid_to_wait:
|
|
|
|
|
args.append(str(pid_to_wait))
|
|
|
|
|
|
|
|
|
|
if perm_exe.exists() and perm_exe.suffix.lower() == ".exe":
|
|
|
|
|
cmd = [str(perm_exe.resolve())] + args
|
|
|
|
|
elif getattr(sys, "frozen", False):
|
|
|
|
|
cmd = [str(Path(sys.executable).resolve())] + args
|
|
|
|
|
else:
|
|
|
|
|
python_exe = Path(sys.executable).parent / "pythonw.exe"
|
|
|
|
|
if not python_exe.exists():
|
|
|
|
|
python_exe = Path(sys.executable)
|
|
|
|
|
cmd = [str(python_exe.resolve()), str(Path(__file__).parent.resolve() / "patcher_gui.py")] + args
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
creation_flags = 0x00000008 | 0x00000200 if os.name == "nt" else 0 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
|
|
|
subprocess.Popen(cmd, creationflags=creation_flags)
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
def ensure_permanent_installation() -> Path:
|
|
|
|
|
"""
|
|
|
|
|
Ensures BetterCurseForge executable, bettercf runtime, and vendor tools are permanently
|
|
|
|
|
installed to %LOCALAPPDATA%\\BetterCurseForge so auto-repatch never breaks.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
PERMANENT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
src_bcf = find_bettercf_source()
|
|
|
|
|
if src_bcf and src_bcf.exists():
|
|
|
|
|
if PERMANENT_BCF.resolve() != src_bcf.resolve():
|
|
|
|
|
if PERMANENT_BCF.exists():
|
|
|
|
|
shutil.rmtree(PERMANENT_BCF, ignore_errors=True)
|
|
|
|
|
shutil.copytree(src_bcf, PERMANENT_BCF)
|
|
|
|
|
|
|
|
|
|
src_vendor = find_vendor_dir()
|
|
|
|
|
if src_vendor and src_vendor.exists():
|
|
|
|
|
if PERMANENT_VENDOR.resolve() != src_vendor.resolve():
|
|
|
|
|
if PERMANENT_VENDOR.exists():
|
|
|
|
|
shutil.rmtree(PERMANENT_VENDOR, ignore_errors=True)
|
|
|
|
|
shutil.copytree(src_vendor, PERMANENT_VENDOR)
|
|
|
|
|
|
2026-10-08 19:18:55 +00:00
|
|
|
src_assets = find_assets_dir()
|
|
|
|
|
if src_assets and src_assets.exists():
|
|
|
|
|
if PERMANENT_ASSETS.resolve() != src_assets.resolve():
|
|
|
|
|
if PERMANENT_ASSETS.exists():
|
|
|
|
|
shutil.rmtree(PERMANENT_ASSETS, ignore_errors=True)
|
|
|
|
|
shutil.copytree(src_assets, PERMANENT_ASSETS)
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
if getattr(sys, "frozen", False):
|
|
|
|
|
curr_exe = Path(sys.executable).resolve()
|
|
|
|
|
if curr_exe != PERMANENT_EXE.resolve():
|
|
|
|
|
shutil.copy2(curr_exe, PERMANENT_EXE)
|
|
|
|
|
else:
|
|
|
|
|
dist_exe = Path(__file__).parent.parent / "dist" / "BetterCurseForge.exe"
|
|
|
|
|
if dist_exe.exists():
|
|
|
|
|
shutil.copy2(dist_exe, PERMANENT_EXE)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return PERMANENT_EXE if PERMANENT_EXE.exists() else Path(sys.executable)
|
|
|
|
|
|
|
|
|
|
def is_autostart_watcher_enabled() -> bool:
|
|
|
|
|
try:
|
|
|
|
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_READ)
|
|
|
|
|
try:
|
|
|
|
|
val, _ = winreg.QueryValueEx(key, WATCHER_VALUE_NAME)
|
|
|
|
|
winreg.CloseKey(key)
|
|
|
|
|
return bool(val)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
winreg.CloseKey(key)
|
|
|
|
|
return False
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def set_autostart_watcher(enable: bool) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE)
|
|
|
|
|
if enable:
|
|
|
|
|
perm_exe = ensure_permanent_installation()
|
|
|
|
|
if perm_exe.suffix.lower() == ".exe":
|
|
|
|
|
cmd = f'"{perm_exe.resolve()}" --watch'
|
|
|
|
|
else:
|
|
|
|
|
python_exe = Path(sys.executable).parent / "pythonw.exe"
|
|
|
|
|
if not python_exe.exists():
|
|
|
|
|
python_exe = Path(sys.executable)
|
|
|
|
|
cmd = f'"{python_exe.resolve()}" "{Path(__file__).parent.resolve() / "patcher_gui.py"}" --watch'
|
|
|
|
|
|
|
|
|
|
winreg.SetValueEx(key, WATCHER_VALUE_NAME, 0, winreg.REG_SZ, cmd)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
|
|
|
|
|
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
|
2026-10-06 16:49:12 +00:00
|
|
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
|
2026-10-06 15:03:16 +00:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
winreg.DeleteValue(key, WATCHER_VALUE_NAME)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
|
2026-10-06 16:49:12 +00:00
|
|
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
|
2026-10-06 15:03:16 +00:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
winreg.CloseKey(key)
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def is_watcher_running() -> bool:
|
|
|
|
|
import ctypes
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
kernel32.OpenMutexW.restype = ctypes.c_void_p
|
|
|
|
|
kernel32.OpenMutexW.argtypes = [ctypes.c_uint, ctypes.c_bool, ctypes.c_wchar_p]
|
|
|
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
|
|
|
SYNCHRONIZE = 0x00100000
|
|
|
|
|
h = kernel32.OpenMutexW(SYNCHRONIZE, False, WATCHER_MUTEX_NAME)
|
|
|
|
|
if h:
|
|
|
|
|
kernel32.CloseHandle(h)
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def start_background_watcher():
|
|
|
|
|
if is_watcher_running():
|
|
|
|
|
return
|
|
|
|
|
perm_exe = ensure_permanent_installation()
|
|
|
|
|
if perm_exe.exists() and perm_exe.suffix.lower() == ".exe":
|
|
|
|
|
cmd_str = f'"{perm_exe.resolve()}" --watch'
|
|
|
|
|
elif getattr(sys, "frozen", False):
|
|
|
|
|
cmd_str = f'"{Path(sys.executable).resolve()}" --watch'
|
|
|
|
|
else:
|
|
|
|
|
python_exe = Path(sys.executable).parent / "pythonw.exe"
|
|
|
|
|
if not python_exe.exists():
|
|
|
|
|
python_exe = Path(sys.executable)
|
|
|
|
|
cmd_str = f'"{python_exe.resolve()}" "{Path(__file__).parent.resolve() / "patcher_gui.py"}" --watch'
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}"
|
|
|
|
|
subprocess.run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd],
|
2026-10-06 16:49:12 +00:00
|
|
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
|
2026-10-06 15:03:16 +00:00
|
|
|
except Exception:
|
|
|
|
|
try:
|
|
|
|
|
creation_flags = 0x08000000 | 0x00000200
|
|
|
|
|
subprocess.Popen(f'start "" {cmd_str}', shell=True, creationflags=creation_flags)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def stop_background_watcher():
|
|
|
|
|
try:
|
|
|
|
|
subprocess.run(
|
2026-10-06 16:49:12 +00:00
|
|
|
["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
2026-10-06 15:03:16 +00:00
|
|
|
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--watch*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
|
2026-10-06 16:49:12 +00:00
|
|
|
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
|
2026-10-06 15:03:16 +00:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, intercept_unpatched: bool = True) -> bool:
|
2026-10-06 15:03:16 +00:00
|
|
|
"""
|
|
|
|
|
Waits for the CurseForge updater/installer to finish writing files.
|
|
|
|
|
Ensures resources/app.asar is present, fully written, and readable.
|
2026-10-12 15:12:24 +00:00
|
|
|
Actively intercepts and kills any unpatched CurseForge.exe instances launched
|
|
|
|
|
by the installer before their window can render on screen.
|
2026-10-06 15:03:16 +00:00
|
|
|
"""
|
|
|
|
|
import time
|
|
|
|
|
start = time.time()
|
|
|
|
|
asar_path = cf_dir / "resources" / "app.asar"
|
|
|
|
|
last_size = -1
|
|
|
|
|
stable_checks = 0
|
|
|
|
|
|
|
|
|
|
while time.time() - start < timeout:
|
2026-10-12 15:12:24 +00:00
|
|
|
# Check in 150ms slices (total ~1.5s per outer cycle) to actively silence unpatched CurseForge
|
|
|
|
|
for _ in range(10):
|
|
|
|
|
if intercept_unpatched:
|
|
|
|
|
pids = get_process_pids("curseforge.exe")
|
|
|
|
|
if pids:
|
|
|
|
|
kill_process_by_name("curseforge.exe")
|
|
|
|
|
if log_fn:
|
|
|
|
|
log_fn("Silencing vanilla launcher in background...")
|
|
|
|
|
time.sleep(0.15)
|
2026-10-06 15:03:16 +00:00
|
|
|
|
|
|
|
|
if is_updater_running():
|
|
|
|
|
stable_checks = 0
|
|
|
|
|
if log_fn:
|
|
|
|
|
log_fn("CurseForge installer is currently running... waiting for completion.")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if asar_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
asar_size = asar_path.stat().st_size
|
|
|
|
|
if asar_size > 5_000_000:
|
|
|
|
|
if asar_size == last_size:
|
|
|
|
|
stable_checks += 1
|
|
|
|
|
if stable_checks >= 2:
|
|
|
|
|
try:
|
|
|
|
|
with open(asar_path, "rb") as f:
|
|
|
|
|
magic = f.read(4)
|
|
|
|
|
if len(magic) == 4:
|
|
|
|
|
if log_fn:
|
|
|
|
|
log_fn(f"Installer finished. app.asar settled ({asar_size // 1048576}MB).")
|
|
|
|
|
return True
|
|
|
|
|
except (PermissionError, OSError):
|
|
|
|
|
stable_checks = 0
|
|
|
|
|
else:
|
|
|
|
|
stable_checks = 0
|
|
|
|
|
last_size = asar_size
|
|
|
|
|
if log_fn:
|
|
|
|
|
log_fn(f"Waiting for app.asar to settle... ({asar_size // 1048576}MB)")
|
|
|
|
|
except (OSError, PermissionError):
|
|
|
|
|
stable_checks = 0
|
|
|
|
|
continue
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Invoked when CurseForge is quitting to apply an update.
|
2026-10-12 15:12:24 +00:00
|
|
|
Spawns the dedicated Splash Updater GUI for a smooth visual transition,
|
|
|
|
|
or falls back to headless repatch if UI cannot launch.
|
2026-10-06 15:03:16 +00:00
|
|
|
"""
|
2026-10-12 15:12:24 +00:00
|
|
|
if launch_splash_updater(pid_to_wait):
|
|
|
|
|
return True
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
import time
|
|
|
|
|
if log_fn is None:
|
|
|
|
|
log_dir = get_user_data_dir()
|
|
|
|
|
watch_log = log_dir / "watcher.log"
|
|
|
|
|
def log_fn(msg):
|
|
|
|
|
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
try:
|
|
|
|
|
with open(watch_log, "a", encoding="utf-8") as f:
|
|
|
|
|
f.write(f"[{ts}] [OnUpdateHook] {msg}\n")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
log_fn(f"Wait-for-update triggered (PID={pid_to_wait})")
|
|
|
|
|
cf_dir = get_curseforge_dir()
|
|
|
|
|
if not cf_dir:
|
|
|
|
|
log_fn("CurseForge directory not found.")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if pid_to_wait:
|
|
|
|
|
log_fn(f"Waiting for PID {pid_to_wait} to terminate...")
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
try:
|
2026-10-12 15:12:24 +00:00
|
|
|
pids = get_process_pids("curseforge.exe")
|
|
|
|
|
if pid_to_wait not in pids:
|
2026-10-06 15:03:16 +00:00
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
break
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn, intercept_unpatched=True)
|
2026-10-06 15:03:16 +00:00
|
|
|
if not settled:
|
|
|
|
|
log_fn("Timed out waiting for installer to settle.")
|
|
|
|
|
return False
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
kill_curseforge()
|
2026-10-06 15:03:16 +00:00
|
|
|
success = False
|
|
|
|
|
for attempt in range(1, 4):
|
|
|
|
|
success = patch_curseforge(log_callback=log_fn)
|
|
|
|
|
if success:
|
|
|
|
|
break
|
|
|
|
|
log_fn(f"Patch attempt {attempt} failed, retrying in 2 seconds...")
|
|
|
|
|
time.sleep(2)
|
|
|
|
|
|
|
|
|
|
if success:
|
|
|
|
|
log_fn("★ CurseForge successfully re-patched after update!")
|
|
|
|
|
log_fn("Launching modded CurseForge...")
|
2026-10-06 16:49:12 +00:00
|
|
|
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
2026-10-06 15:03:16 +00:00
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
log_fn("Failed to re-patch CurseForge after update.")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def run_watcher_loop():
|
|
|
|
|
"""
|
|
|
|
|
Resilient Win32 filesystem watcher with kernel event signaling + 3s heartbeat fallback.
|
|
|
|
|
0% CPU consumption during idle.
|
|
|
|
|
Instantly and reliably re-patches CurseForge when updated.
|
|
|
|
|
"""
|
|
|
|
|
import ctypes
|
|
|
|
|
import time
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
log_dir = get_user_data_dir()
|
|
|
|
|
watch_log = log_dir / "watcher.log"
|
|
|
|
|
|
|
|
|
|
def wlog(msg):
|
|
|
|
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
try:
|
|
|
|
|
with open(watch_log, "a", encoding="utf-8") as f:
|
|
|
|
|
f.write(f"[{ts}] {msg}\n")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
|
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
|
|
|
|
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
|
|
|
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
|
|
|
|
|
|
|
|
mutex = kernel32.CreateMutexW(None, True, WATCHER_MUTEX_NAME)
|
|
|
|
|
if kernel32.GetLastError() == 183: # ERROR_ALREADY_EXISTS
|
|
|
|
|
wlog("Watcher already running (mutex exists). Exiting duplicate.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
wlog("Watcher started.")
|
|
|
|
|
cf_dir = get_curseforge_dir()
|
|
|
|
|
if not cf_dir:
|
|
|
|
|
wlog("CurseForge directory not found. Watcher exiting.")
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-12 15:12:24 +00:00
|
|
|
last_splash_launch = 0.0
|
|
|
|
|
|
2026-10-06 15:03:16 +00:00
|
|
|
def check_and_repatch(trigger_reason: str):
|
2026-10-12 15:12:24 +00:00
|
|
|
nonlocal last_splash_launch
|
|
|
|
|
now = time.time()
|
|
|
|
|
if is_splash_running():
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-13 17:33:59 +00:00
|
|
|
# ONLY trigger if CurseForge is genuinely unpatched
|
|
|
|
|
if not is_curseforge_patched(cf_dir):
|
|
|
|
|
if now - last_splash_launch < 15.0:
|
2026-10-12 15:12:24 +00:00
|
|
|
return
|
|
|
|
|
last_splash_launch = now
|
2026-10-13 17:33:59 +00:00
|
|
|
wlog(f"CurseForge unpatched state detected ({trigger_reason}). Launching Splash Updater...")
|
2026-10-12 15:12:24 +00:00
|
|
|
ok = launch_splash_updater()
|
|
|
|
|
if not ok:
|
|
|
|
|
wlog("Could not launch Splash Updater; falling back to background patch.")
|
2026-10-13 17:33:59 +00:00
|
|
|
was_running = is_curseforge_running()
|
|
|
|
|
was_intercepted = [False]
|
|
|
|
|
def on_fb_log(msg: str):
|
|
|
|
|
if "silenc" in msg.lower():
|
|
|
|
|
was_intercepted[0] = True
|
|
|
|
|
wlog(msg)
|
|
|
|
|
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=on_fb_log, intercept_unpatched=True)
|
2026-10-12 15:12:24 +00:00
|
|
|
if settled:
|
2026-10-06 15:03:16 +00:00
|
|
|
kill_curseforge()
|
2026-10-12 15:12:24 +00:00
|
|
|
success = False
|
|
|
|
|
for attempt in range(1, 4):
|
|
|
|
|
success = patch_curseforge(log_callback=wlog)
|
|
|
|
|
if success:
|
|
|
|
|
break
|
|
|
|
|
time.sleep(2)
|
2026-10-06 15:03:16 +00:00
|
|
|
if success:
|
2026-10-12 15:12:24 +00:00
|
|
|
wlog("★ CurseForge successfully re-patched after update!")
|
2026-10-13 17:33:59 +00:00
|
|
|
if was_running or was_intercepted[0]:
|
|
|
|
|
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
2026-10-12 15:12:24 +00:00
|
|
|
else:
|
|
|
|
|
wlog("Failed to re-patch CurseForge after update.")
|
2026-10-06 15:03:16 +00:00
|
|
|
else:
|
2026-10-12 15:12:24 +00:00
|
|
|
wlog("Timed out waiting for installer files to settle.")
|
2026-10-06 15:03:16 +00:00
|
|
|
|
|
|
|
|
check_and_repatch("Startup Check")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
time.sleep(3)
|
|
|
|
|
try:
|
|
|
|
|
check_and_repatch("Heartbeat Check")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
wlog(f"Error during check_and_repatch: {e}")
|
|
|
|
|
finally:
|
|
|
|
|
if mutex:
|
|
|
|
|
kernel32.CloseHandle(mutex)
|
|
|
|
|
wlog("Watcher stopped.")
|
|
|
|
|
|