BetterCurseForge/patcher/patch_core.py

1063 lines
39 KiB
Python
Raw Normal View History

"""
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
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"
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("<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:
"""
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 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("<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:
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. Zero-Ad & Themes Patch in desktop.js
desktop_js = temp_extract / "dist" / "desktop" / "desktop.js"
if desktop_js.exists():
content = desktop_js.read_text(encoding="utf-8")
# Clean up any legacy or duplicate __orig_eV replacements
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=>{")
# Idempotently disable React ad component (eV -> return null)
if "/*BCF_AD_OFF*/" not in content and "eV=e=>{let" in content:
content = content.replace("eV=e=>{let", "/*BCF_AD_OFF*/eV=e=>{return null;let", 1)
log_callback("[+] Disabled React ad component (eV -> return null)")
if "showSwapAdsButton:T" in content:
content = content.replace("showSwapAdsButton:T", "showSwapAdsButton:!1")
log_callback("[+] Disabled swap ads button")
if "R=e=>!!e.forceUnlock||!e.isPremium||!!M" in content:
content = content.replace("R=e=>!!e.forceUnlock||!e.isPremium||!!M", "R=e=>!0")
log_callback("[+] Unlocked all native premium themes (Settings route)")
if "P=e=>!!e.forceUnlock||!e.isPremium||f" in content:
content = content.replace("P=e=>!!e.forceUnlock||!e.isPremium||f", "P=e=>!0")
log_callback("[+] Unlocked all native premium themes (Popup route)")
if "return!r?.forceUnlock&&r?.isPremium&&!t?u.Sx.Classic:n" in content:
content = content.replace("return!r?.forceUnlock&&r?.isPremium&&!t?u.Sx.Classic:n", "return n")
log_callback("[+] Unlocked premium theme state validation")
if "function iy(e){return e.subscription.plan?.active}" in content:
content = content.replace("function iy(e){return e.subscription.plan?.active}", "function iy(e){return !0}")
log_callback("[+] Unlocked subscription plan status for themes")
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-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .sprite-icon {
opacity: 0 !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .thumb-icon {
background-color: transparent !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active {
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 ═══ */" not in css_text:
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 = '<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
# 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)
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.")