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.'); 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 shutil
import hashlib import hashlib
import winreg import winreg
import time
import subprocess import subprocess
from pathlib import Path from pathlib import Path
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run" RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher" WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex" WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex"
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge" PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe" PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
PERMANENT_BCF = PERMANENT_DIR / "bettercf" PERMANENT_BCF = PERMANENT_DIR / "bettercf"
PERMANENT_VENDOR = PERMANENT_DIR / "vendor" 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: def get_curseforge_dir() -> Path | None:
# 1. Registry lookup # 1. Registry lookup
try: try:
@ -67,7 +112,11 @@ def get_curseforge_dir() -> Path | None:
def kill_curseforge(): def kill_curseforge():
try: 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: except Exception:
pass pass
@ -97,23 +146,46 @@ def read_exe_hash(exe_path: Path) -> str | None:
return None return None
def update_exe_hash(exe_path: Path, new_hash: str) -> bool: 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
sig = "OWEASARSIG".encode("utf-16le") in-place with automatic retry handling for temporary file locks.
idx = data.rfind(sig) """
if idx == -1: if len(new_hash) != 64:
idx = data.rfind(b"OWEASARSIG") raise ValueError(f"Invalid ASAR hash length: {len(new_hash)}")
if idx == -1:
raise ValueError("OWEASARSIG signature block not found in CurseForge.exe") last_err = None
val_idx = data.find(b'"value":"', idx) for attempt in range(12):
if val_idx == -1: try:
raise ValueError('"value":" field not found in OWEASARSIG resource') with open(exe_path, "r+b") as f:
hash_start = val_idx + 9 data = f.read()
old_hash = data[hash_start:hash_start+64].decode("ascii", errors="replace") sig = "OWEASARSIG".encode("utf-16le")
data[hash_start:hash_start+64] = new_hash.encode("ascii") idx = data.rfind(sig)
with open(exe_path, "wb") as f: if idx == -1:
f.write(data) idx = data.rfind(b"OWEASARSIG")
return True 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: def get_user_data_dir() -> Path:
appdata = Path(os.environ.get("APPDATA", "")) 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, text=True,
shell=True, shell=True,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
timeout=60 timeout=60,
creationflags=CREATE_NO_WINDOW
) )
if r.returncode == 0: if r.returncode == 0:
return (True, r.stdout.strip()) return (True, r.stdout.strip())
@ -257,9 +330,16 @@ def is_curseforge_patched(cf_dir: Path) -> bool:
return is_asar_patched(asar_path) return is_asar_patched(asar_path)
def patch_curseforge(log_callback=print) -> bool: 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() cf_dir = get_curseforge_dir()
if not cf_dir: if not cf_dir:
log_callback("CurseForge installation directory not found!") log_callback("CurseForge installation directory not found!")
patch_lock.__exit__(None, None, None)
return False return False
resources_dir = cf_dir / "resources" 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(): if not asar_path.exists() or not exe_path.exists():
log_callback("CurseForge resources/app.asar or CurseForge.exe missing!") log_callback("CurseForge resources/app.asar or CurseForge.exe missing!")
patch_lock.__exit__(None, None, None)
return False return False
asar_bak = resources_dir / "app.asar.vanilla" 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) ok, err = run_asar_tool(["extract", str(source_asar), str(temp_extract)], cf_dir=cf_dir)
if not ok: if not ok:
log_callback(f"Failed to extract asar: {err}") log_callback(f"Failed to extract asar: {err}")
patch_lock.__exit__(None, None, None)
return False return False
try: try:
@ -557,17 +639,18 @@ try {
log_callback(f"Failed to pack asar: {err}") log_callback(f"Failed to pack asar: {err}")
return False 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) new_hash = calc_asar_header_hash(asar_path)
log_callback(f"New ASAR header hash: {new_hash}") 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) shutil.copy2(exe_bak, exe_path)
update_exe_hash(exe_path, new_hash) update_exe_hash(exe_path, new_hash)
log_callback("[+] Updated CurseForge.exe integrity signature") log_callback("[+] Updated CurseForge.exe integrity signature")
finally: finally:
if temp_extract.exists(): if temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True) shutil.rmtree(temp_extract, ignore_errors=True)
patch_lock.__exit__(None, None, None)
try: try:
ensure_permanent_installation() ensure_permanent_installation()
@ -626,7 +709,7 @@ def is_curseforge_running() -> bool:
try: try:
out = subprocess.check_output( out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"], ["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 return "CurseForge.exe" in out
except Exception: except Exception:
@ -636,7 +719,7 @@ def is_updater_running() -> bool:
try: try:
out = subprocess.check_output( out = subprocess.check_output(
["tasklist", "/FO", "CSV", "/NH"], ["tasklist", "/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
) )
for line in out.splitlines(): for line in out.splitlines():
line = line.strip().lower() line = line.strip().lower()
@ -717,7 +800,7 @@ def set_autostart_watcher(enable: bool) -> bool:
subprocess.run( subprocess.run(
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher", ["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"], "/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
capture_output=True, timeout=5 capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
) )
except Exception: except Exception:
pass pass
@ -728,7 +811,7 @@ def set_autostart_watcher(enable: bool) -> bool:
pass pass
try: try:
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"], subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
capture_output=True, timeout=5) capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW)
except Exception: except Exception:
pass pass
winreg.CloseKey(key) winreg.CloseKey(key)
@ -766,7 +849,7 @@ def start_background_watcher():
try: try:
ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}" ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}"
subprocess.run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd], 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: except Exception:
try: try:
creation_flags = 0x08000000 | 0x00000200 creation_flags = 0x08000000 | 0x00000200
@ -777,9 +860,9 @@ def start_background_watcher():
def stop_background_watcher(): def stop_background_watcher():
try: try:
subprocess.run( 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 }"], "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: except Exception:
pass pass
@ -860,7 +943,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
try: try:
out = subprocess.check_output( out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"], ["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: if str(pid_to_wait) not in out:
break break
@ -890,7 +973,7 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
if success: if success:
log_fn("★ CurseForge successfully re-patched after update!") log_fn("★ CurseForge successfully re-patched after update!")
log_fn("Launching modded CurseForge...") 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 return True
else: else:
log_fn("Failed to re-patch CurseForge after update.") log_fn("Failed to re-patch CurseForge after update.")
@ -957,7 +1040,7 @@ def run_watcher_loop():
wlog("★ CurseForge successfully re-patched after update!") wlog("★ CurseForge successfully re-patched after update!")
if was_running: if was_running:
wlog("Relaunching modded CurseForge...") wlog("Relaunching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")]) subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
else: else:
wlog("Failed to re-patch CurseForge after update.") wlog("Failed to re-patch CurseForge after update.")
else: else:

View file

@ -396,32 +396,44 @@ class BetterCFApp:
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1: try:
arg = sys.argv[1].lower() if len(sys.argv) > 1:
if arg == "--watch": arg = sys.argv[1].lower()
# Silent background watcher mode if arg == "--watch":
patch_core.run_watcher_loop() patch_core.run_watcher_loop()
sys.exit(0) sys.exit(0)
elif arg == "--wait-for-update": elif arg == "--wait-for-update":
pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
patch_core.wait_for_update_and_repatch(pid) patch_core.wait_for_update_and_repatch(pid)
sys.exit(0) sys.exit(0)
elif arg == "--ensure-watcher": elif arg == "--ensure-watcher":
if not patch_core.is_watcher_running(): if not patch_core.is_watcher_running():
patch_core.start_background_watcher() patch_core.start_background_watcher()
sys.exit(0) sys.exit(0)
elif arg == "patch": elif arg == "patch":
patch_core.patch_curseforge() patch_core.patch_curseforge()
sys.exit(0) sys.exit(0)
elif arg == "unpatch": elif arg == "unpatch":
patch_core.unpatch_curseforge() patch_core.unpatch_curseforge()
sys.exit(0) sys.exit(0)
elif arg == "status": elif arg == "status":
cf = patch_core.get_curseforge_dir() cf = patch_core.get_curseforge_dir()
print("CurseForge:", cf) print("CurseForge:", cf)
print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False) print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False)
print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED") print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED")
sys.exit(0) sys.exit(0)
app = BetterCFApp() app = BetterCFApp()
app.run() 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