fix(repatch): eliminate pop-up cmd windows, race conditions, and update hook crashes
This commit is contained in:
parent
c20abd8790
commit
945302a3cb
3 changed files with 155 additions and 102 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue