fix(repatch): eliminate pop-up cmd windows, race conditions, and update hook crashes

This commit is contained in:
tomasalias 2026-10-06 18:49:12 +02:00
parent c20abd8790
commit 945302a3cb
3 changed files with 155 additions and 102 deletions

View file

@ -266,45 +266,3 @@ ipcMain.handle('bcf:info', () => ({
console.log('[BetterCF] Main process hooks installed.');
// ── Auto-Repatch on Update Interceptor ────────────────────────
try {
const { spawn } = await import('node:child_process');
const patcherExe = join(process.env.LOCALAPPDATA || '', 'BetterCurseForge', 'BetterCurseForge.exe');
const onUpdateTriggered = () => {
try {
if (existsSync(patcherExe)) {
spawn(patcherExe, ['--wait-for-update', process.pid.toString()], {
detached: true,
stdio: 'ignore'
}).unref();
console.log('[BetterCF] Update pending. Spawned patcher --wait-for-update');
}
} catch (e) {
console.error('[BetterCF] Failed to spawn update patcher:', e);
}
};
// 1. Check if curseforge-updater has a pending update when quitting
app.on('before-quit', () => {
const pendingInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'pending', 'setup.exe');
const directInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'installer.exe');
if (existsSync(pendingInstaller) || existsSync(directInstaller)) {
onUpdateTriggered();
}
});
// 2. Ensure watcher daemon is running
setTimeout(() => {
try {
if (existsSync(patcherExe)) {
spawn(patcherExe, ['--ensure-watcher'], {
detached: true,
stdio: 'ignore'
}).unref();
}
} catch {}
}, 4000);
} catch (err) {
console.error('[BetterCF] Auto-repatch hook initialization error:', err);
}

View file

@ -12,17 +12,62 @@ 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:
@ -67,7 +112,11 @@ def get_curseforge_dir() -> Path | None:
def kill_curseforge():
try:
subprocess.run(["taskkill", "/f", "/im", "CurseForge.exe"], capture_output=True, timeout=10)
subprocess.run(
["taskkill", "/f", "/im", "CurseForge.exe"],
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
)
time.sleep(0.5)
except Exception:
pass
@ -97,8 +146,18 @@ def read_exe_hash(exe_path: Path) -> str | None:
return None
def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
with open(exe_path, "rb") as f:
data = bytearray(f.read())
"""
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:
@ -110,10 +169,23 @@ def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
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")
data[hash_start:hash_start+64] = new_hash.encode("ascii")
with open(exe_path, "wb") as f:
f.write(data)
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", ""))
@ -231,7 +303,8 @@ def run_asar_tool(args: list[str], cf_dir: Path | None = None) -> tuple[bool, st
text=True,
shell=True,
stdin=subprocess.DEVNULL,
timeout=60
timeout=60,
creationflags=CREATE_NO_WINDOW
)
if r.returncode == 0:
return (True, r.stdout.strip())
@ -257,9 +330,16 @@ def is_curseforge_patched(cf_dir: Path) -> bool:
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"
@ -268,6 +348,7 @@ def patch_curseforge(log_callback=print) -> bool:
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"
@ -316,6 +397,7 @@ def patch_curseforge(log_callback=print) -> bool:
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:
@ -557,10 +639,10 @@ try {
log_callback(f"Failed to pack asar: {err}")
return False
# 7. Compute new hash and update CurseForge.exe
# 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}")
# Always restore clean vanilla exe before patching 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")
@ -568,6 +650,7 @@ try {
finally:
if temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
patch_lock.__exit__(None, None, None)
try:
ensure_permanent_installation()
@ -626,7 +709,7 @@ def is_curseforge_running() -> bool:
try:
out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
text=True, stderr=subprocess.DEVNULL
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
)
return "CurseForge.exe" in out
except Exception:
@ -636,7 +719,7 @@ def is_updater_running() -> bool:
try:
out = subprocess.check_output(
["tasklist", "/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
)
for line in out.splitlines():
line = line.strip().lower()
@ -717,7 +800,7 @@ def set_autostart_watcher(enable: bool) -> bool:
subprocess.run(
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
capture_output=True, timeout=5
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
)
except Exception:
pass
@ -728,7 +811,7 @@ def set_autostart_watcher(enable: bool) -> bool:
pass
try:
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
capture_output=True, timeout=5)
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
except Exception:
pass
winreg.CloseKey(key)
@ -766,7 +849,7 @@ def start_background_watcher():
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)
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
except Exception:
try:
creation_flags = 0x08000000 | 0x00000200
@ -777,9 +860,9 @@ def start_background_watcher():
def stop_background_watcher():
try:
subprocess.run(
["powershell", "-NoProfile", "-Command",
["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
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
)
except Exception:
pass
@ -860,7 +943,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
try:
out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"],
text=True, stderr=subprocess.DEVNULL
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
)
if str(pid_to_wait) not in out:
break
@ -890,7 +973,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
if success:
log_fn("★ CurseForge successfully re-patched after update!")
log_fn("Launching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
return True
else:
log_fn("Failed to re-patch CurseForge after update.")
@ -957,7 +1040,7 @@ def run_watcher_loop():
wlog("★ CurseForge successfully re-patched after update!")
if was_running:
wlog("Relaunching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
else:
wlog("Failed to re-patch CurseForge after update.")
else:

View file

@ -396,10 +396,10 @@ class BetterCFApp:
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
try:
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
if arg == "--watch":
# Silent background watcher mode
patch_core.run_watcher_loop()
sys.exit(0)
elif arg == "--wait-for-update":
@ -425,3 +425,15 @@ if __name__ == "__main__":
app = BetterCFApp()
app.run()
except SystemExit:
pass
except Exception as e:
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"):
try:
log_dir = patch_core.get_user_data_dir()
with open(log_dir / "watcher.log", "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [BackgroundError] {e}\n")
except Exception:
pass
else:
raise