diff --git a/bettercf/core/main-process.js b/bettercf/core/main-process.js index 54f3198..fad95af 100644 --- a/bettercf/core/main-process.js +++ b/bettercf/core/main-process.js @@ -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); -} diff --git a/patcher/patch_core.py b/patcher/patch_core.py index b5e1df2..abbdd90 100644 --- a/patcher/patch_core.py +++ b/patcher/patch_core.py @@ -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,23 +146,46 @@ 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()) - 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") - data[hash_start:hash_start+64] = new_hash.encode("ascii") - with open(exe_path, "wb") as f: - f.write(data) - return True + """ + 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", "")) @@ -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,17 +639,18 @@ 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 - shutil.copy2(exe_bak, exe_path) + 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() @@ -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: diff --git a/patcher/patcher_gui.py b/patcher/patcher_gui.py index c84ceee..9599c63 100644 --- a/patcher/patcher_gui.py +++ b/patcher/patcher_gui.py @@ -396,32 +396,44 @@ class BetterCFApp: # ═══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": - 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": - 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) - sys.exit(0) - elif arg == "--ensure-watcher": - if not patch_core.is_watcher_running(): - patch_core.start_background_watcher() - sys.exit(0) - elif arg == "patch": - patch_core.patch_curseforge() - sys.exit(0) - elif arg == "unpatch": - patch_core.unpatch_curseforge() - sys.exit(0) - elif arg == "status": - cf = patch_core.get_curseforge_dir() - print("CurseForge:", cf) - print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False) - print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED") - sys.exit(0) + try: + if len(sys.argv) > 1: + arg = sys.argv[1].lower() + if arg == "--watch": + patch_core.run_watcher_loop() + sys.exit(0) + elif arg == "--wait-for-update": + 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) + sys.exit(0) + elif arg == "--ensure-watcher": + if not patch_core.is_watcher_running(): + patch_core.start_background_watcher() + sys.exit(0) + elif arg == "patch": + patch_core.patch_curseforge() + sys.exit(0) + elif arg == "unpatch": + patch_core.unpatch_curseforge() + sys.exit(0) + elif arg == "status": + cf = patch_core.get_curseforge_dir() + print("CurseForge:", cf) + print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False) + print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED") + sys.exit(0) - app = BetterCFApp() - app.run() + 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