""" 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 import time import subprocess import re from pathlib import Path CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0 RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run" WATCHER_VALUE_NAME = "BetterCurseForgeWatcher" WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex" PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex" PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge" PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe" PERMANENT_BCF = PERMANENT_DIR / "bettercf" PERMANENT_VENDOR = PERMANENT_DIR / "vendor" PERMANENT_ASSETS = PERMANENT_DIR / "assets" 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 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 def kill_curseforge(): try: subprocess.run( ["taskkill", "/f", "/im", "CurseForge.exe"], capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW ) time.sleep(0.5) 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(" 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: """ 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 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 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 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, timeout=60, creationflags=CREATE_NO_WINDOW ) 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(" bool: asar_path = cf_dir / "resources" / "app.asar" return is_asar_patched(asar_path) def patch_curseforge(log_callback=print) -> bool: 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 cf_dir = get_curseforge_dir() if not cf_dir: log_callback("CurseForge installation directory not found!") patch_lock.__exit__(None, None, None) 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!") patch_lock.__exit__(None, None, None) 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}") patch_lock.__exit__(None, None, None) return False try: # 1. Themes Patch in desktop.js (Ads are fully handled by desktop.css & network blocking) desktop_js = temp_extract / "dist" / "desktop" / "desktop.js" if desktop_js.exists(): content = desktop_js.read_text(encoding="utf-8") # 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)") 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=>{") # 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: log_callback("[+] Unlocked premium theme state validation") # 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)") 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 */ 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 { opacity: 0 !important; } 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 { background-color: transparent !important; } 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) { 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") 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") # 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 = '' html_content = html_content.replace("", f"{bcf_script}") 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 # 7. Compute new hash and update CurseForge.exe directly in-place new_hash = calc_asar_header_hash(asar_path) log_callback(f"New ASAR header hash: {new_hash}") if not exe_path.exists() and exe_bak.exists(): shutil.copy2(exe_bak, exe_path) 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) patch_lock.__exit__(None, None, None) 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: try: out = subprocess.check_output( ["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"], text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW ) return "CurseForge.exe" in out except Exception: return False def is_updater_running() -> bool: try: out = subprocess.check_output( ["tasklist", "/FO", "CSV", "/NH"], text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW ) for line in out.splitlines(): line = line.strip().lower() if not line: continue parts = [p.strip('"') for p in line.split('","')] if parts: pname = parts[0] if pname in ("setup.exe", "installer.exe") or ("curseforge" in pname and "updater" in pname): return True except Exception: pass return False 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) 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) 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"], capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW ) except Exception: pass else: try: winreg.DeleteValue(key, WATCHER_VALUE_NAME) except FileNotFoundError: pass try: subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"], capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW) 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], capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW) 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( ["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--watch*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW ) except Exception: pass def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) -> bool: """ Waits for the CurseForge updater/installer to finish writing files. Ensures resources/app.asar is present, fully written, and readable. """ import time start = time.time() asar_path = cf_dir / "resources" / "app.asar" last_size = -1 stable_checks = 0 while time.time() - start < timeout: time.sleep(1.5) 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. Waits for the old CurseForge instance to exit, waits for the installer to finish, and immediately repatches before/as CurseForge restarts. """ 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: out = subprocess.check_output( ["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"], text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW ) if str(pid_to_wait) not in out: break except Exception: break time.sleep(0.5) settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn) if not settled: log_fn("Timed out waiting for installer to settle.") return False was_running = is_curseforge_running() if was_running: log_fn("CurseForge launched unpatched by installer. Terminating to apply patch...") kill_curseforge() time.sleep(1.5) 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...") subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW) 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 def check_and_repatch(trigger_reason: str): if not is_curseforge_patched(cf_dir): wlog(f"CurseForge unpatched state detected ({trigger_reason}). Waiting for files to settle...") settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=wlog) if settled: was_running = is_curseforge_running() if was_running: wlog("CurseForge is currently running unpatched. Closing it to apply BetterCurseForge patch...") kill_curseforge() time.sleep(1.5) wlog("Applying BetterCurseForge patch...") success = False for attempt in range(1, 4): success = patch_curseforge(log_callback=wlog) if success: break wlog(f"Patch attempt {attempt} failed, retrying in 2 seconds...") time.sleep(2) if success: wlog("★ CurseForge successfully re-patched after update!") if was_running: wlog("Relaunching modded CurseForge...") subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW) else: wlog("Failed to re-patch CurseForge after update.") else: wlog("Timed out waiting for installer files to settle.") 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.")