Compare commits

...

10 commits

14 changed files with 800 additions and 208 deletions

65
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Build Windows Executable
on:
push:
branches:
- master
- main
tags:
- 'v*'
pull_request:
branches:
- master
- main
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build BetterCurseForge.exe
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pyinstaller pillow pywin32
- name: Build executable with PyInstaller
run: |
python -m PyInstaller BetterCurseForge.spec --noconfirm
- name: Verify build output
run: |
if (-not (Test-Path "dist/BetterCurseForge.exe")) {
Write-Error "dist/BetterCurseForge.exe was not created!"
exit 1
}
Get-Item "dist/BetterCurseForge.exe" | Select-Object Name, Length, LastWriteTime
- name: Upload executable artifact
uses: actions/upload-artifact@v4
with:
name: BetterCurseForge-Windows
path: dist/BetterCurseForge.exe
if-no-files-found: error
retention-days: 30
- name: Attach executable to GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: dist/BetterCurseForge.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View file

@ -36,3 +36,9 @@ desktop.ini
.idea/
*.swp
*.swo
# Local utility tools
tools/
update_icon.bat
update_icon.py

View file

@ -5,8 +5,8 @@ a = Analysis(
['patcher\\patcher_gui.py'],
pathex=[],
binaries=[],
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.')],
hiddenimports=[],
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.'), ('assets', 'assets')],
hiddenimports=['PIL', 'PIL.Image', 'PIL.ImageTk'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@ -35,4 +35,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='assets\\icon.ico',
)

227
README.md
View file

@ -1,114 +1,73 @@
# BetterCurseForge
<div align="center">
<img src="assets/icon_512.png" alt="BetterCurseForge logo" width="96" height="96" />
<img src="docs/public/logo.svg" alt="BetterCurseForge Logo" width="128" height="128" />
### The modern, lightweight client modification for CurseForge Launcher.
A patch for the CurseForge launcher on Windows. It blocks ads, adds a theme and plugin system, unlocks the subscriber-only skins, and re-applies itself after CurseForge updates.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-Windows-0078D6.svg?logo=windows)](https://github.com)
[![Zero Ads](https://img.shields.io/badge/Ads-Completely%20Blocked-success.svg)](#features)
[![Auto Repatch](https://img.shields.io/badge/Auto--Repatch-Autonomous-brightgreen.svg)](#-how-auto-repatch-works)
**Zero Ads • Custom Themes • Unlocked Skins • Extensible Plugins • Autonomous Auto-Repatch**
## What it does
</div>
- **Ad blocking.** Requests to the Overwolf, Adrta and DoubleClick ad servers are blocked at the Electron session level, and some CSS collapses whatever empty ad frames and banners are left. Ad elements are hidden, not removed from the DOM, because removing them breaks React and causes `insertBefore` errors.
- **Themes.** Themes show up in the launcher's own Settings > Themes page. CSS is injected live, so you don't need to restart to see changes. One theme is included, Purple Night.
- **Subscriber skins.** The official skins that normally need a CurseForge subscription (PixelForge, LifeForge, FantasyForge, Halloween and the rest) are unlocked.
- **Plugins.** Plugins are small JS modules with `onEnable` and `onDisable` hooks, scoped styling, and IPC to the main process. There's a sample one called Compact Mode.
- **Updates.** When CurseForge updates itself, the patch gets re-applied automatically. More on that [below](#how-the-repatch-works).
---
## Install
## ✨ Features
1. Download `BetterCurseForge.exe` from [Releases](https://github.com/tomasalias/BetterCurseForge/releases).
2. Run it and click **Patch CurseForge**.
3. Start CurseForge as usual.
- 🚫 **Complete Zero-Ad Experience**: Multi-layer ad elimination without breaking React Fiber:
- Electron `session.webRequest` network request blocker stops Overwolf/Adrta/DoubleClick ad calls before they start.
- Fail-safe CSS rules completely neutralize, hide, and collapse all ad frames, banners, and swap buttons to 0px.
- Safe non-destructive DOM handling preserves React Fiber reconciliation, preventing `insertBefore` exceptions.
- 🎨 **First-Class Theme Engine**:
- Seamlessly embedded into CurseForge's native **Settings > Themes** screen.
- Live CSS injection with instant hot-reloading (no launcher restarts needed).
- Bundled with **Purple Night**—a custom dark theme featuring violet accents, sleek card elevations, and custom badge styling.
- 🔓 **Unlocked Native Premium Themes & Skins**:
- Unlocks all official CurseForge subscriber skins (PixelForge, LifeForge, FantasyForge, Halloween, etc.) without requiring a CurseForge subscription.
- 🧩 **Extensible Plugin System**:
- Modular plugin architecture with lifecycle hooks (`onEnable`, `onDisable`), safe IPC communication, and scoped styling.
- Includes sample plugins like **Compact Mode**.
- 🛡️ **Autonomous Auto-Repatch Engine**:
- **Survives in-app CurseForge updates automatically!**
- Offline, zero-delay ASAR extraction and repacking (< 3 seconds) using CurseForge's internal Node v24 runtime—no internet connection or external npm/npx required.
- Synchronizes Overwolf's binary `OWEASARSIG` integrity hashes directly inside `CurseForge.exe`.
- Background watcher daemon ensures your modifications are immediately re-applied when CurseForge updates.
---
## 🚀 Quick Start
### Easy Installation (GUI)
1. Download the latest `BetterCurseForge.exe` from [Releases](https://github.com).
2. Run `BetterCurseForge.exe`.
3. Click **Patch CurseForge**.
4. Launch CurseForge Launcher—your modded client is ready to use!
### Command-Line Interface (CLI)
`BetterCurseForge.exe` can also be run directly from PowerShell or Command Prompt:
It also works from PowerShell or Command Prompt:
```bash
# Check current patch and watcher status
# show patch and watcher status
.\BetterCurseForge.exe status
# Apply BetterCurseForge patch
# apply the patch
.\BetterCurseForge.exe patch
# Restore original vanilla CurseForge
# go back to stock CurseForge
.\BetterCurseForge.exe unpatch
# Run background watcher daemon directly
# run the background watcher in the foreground
.\BetterCurseForge.exe watch
# open the splash updater by hand
.\BetterCurseForge.exe --repatch-splash
```
---
## How the repatch works
## 🔄 How Auto-Repatch Works
When CurseForge downloads an update, the Overwolf installer overwrites `resources/app.asar` with a stock copy and relaunches `CurseForge.exe`. That undoes the patch, and the unpatched launcher would normally appear on screen. BetterCurseForge deals with it like this:
Whenever CurseForge downloads an update, the Overwolf installer replaces `resources/app.asar` with a vanilla archive and restarts `CurseForge.exe`. BetterCurseForge guarantees you never lose your patch:
A small watcher process sits in the background and watches CurseForge's install directory. When it sees an update or an unpatched `app.asar`, it polls for a new `CurseForge.exe` every 150 ms and kills the one the installer starts, so the vanilla launcher never gets drawn. Then it opens a splash window with a progress bar and runs the repatch.
```mermaid
flowchart TD
A[CurseForge Launcher Update] -->|Installer downloads update| B[CurseForge Quitting / Replacing Files]
B -->|Watcher Detects Update| C[Auto-Repatch Triggered]
C -->|Wait for Installer Files to Settle| D[Settle Verified]
D -->|Offline Extraction & Patch <3s| E[Dist & BetterCF Injected]
E -->|Recalculate ASAR SHA-256| F[Synchronize OWEASARSIG in CurseForge.exe]
F -->|Autonomous Relaunch| G[Modded CurseForge Launcher Ready!]
The repatch unpacks and repacks `app.asar` using the Node runtime that ships inside CurseForge (`ELECTRON_RUN_AS_NODE=1`), so it needs no internet connection and no separate Node install. It takes a few seconds. It also updates the `OWEASARSIG` integrity signature stored in `CurseForge.exe` to match the new archive. When that's done, CurseForge relaunches.
Two named Windows mutexes, `BetterCurseForgePatchMutex` and `BetterCurseForgeSplashMutex`, stop two patch runs or two splash windows from happening at the same time.
## Themes and plugins
Everything user-facing lives in `%APPDATA%\CurseForge\BetterCurseForge\`:
```
themes/
purple-night.css
purple-night.json
plugins/
compact-mode/
manifest.json
index.js
```
1. **Permanent Daemon Installation**: On first patch, BetterCurseForge installs to `%LOCALAPPDATA%\BetterCurseForge` with an autostart registry entry in `HKCU\...\Run` and a task scheduler fallback.
2. **Global Mutex Synchronization**: Uses Windows kernel named mutexes (`BetterCurseForgePatchMutex`) to guarantee atomic, single-process patching with zero race conditions.
3. **Resilient Win32 Watcher Daemon**: A silent background process monitors CurseForge's installation directory with 0% CPU consumption. If an unpatched state is detected (e.g., after an update), it waits for installer files to settle, terminates the unpatched process, reapplies the patch, and relaunches the modded client seamlessly without console window popups.
4. **100% Offline Zero-Delay ASAR Engine**: Unpacking and repacking use bundled tools combined with CurseForge's built-in Node runtime (`ELECTRON_RUN_AS_NODE=1`), completing the entire repatch cycle in 2–3 seconds without requiring an internet connection.
The GUI has **Open Themes Folder** and **Open Plugins Folder** buttons if you don't want to dig for it.
---
### Writing a theme
## 🎨 Themes & Plugins
User configurations, themes, and plugins are stored in your AppData directory:
```
%APPDATA%\CurseForge\BetterCurseForge\
├── themes/
│ ├── purple-night.css
│ └── purple-night.json
└── plugins/
└── compact-mode/
├── manifest.json
└── index.js
```
You can open these folders anytime via the **Open Themes Folder** and **Open Plugins Folder** buttons in the BetterCurseForge GUI.
### Creating a Custom Theme
Create a CSS file and manifest in `%APPDATA%\CurseForge\BetterCurseForge\themes\my-theme.css`:
A theme is a CSS file plus a JSON manifest with the same name. For example, `themes/my-theme.json`:
```json
{
@ -120,6 +79,8 @@ Create a CSS file and manifest in `%APPDATA%\CurseForge\BetterCurseForge\themes\
}
```
and `themes/my-theme.css`:
```css
:root {
--surface-brand-brand: #10b981 !important;
@ -127,32 +88,83 @@ Create a CSS file and manifest in `%APPDATA%\CurseForge\BetterCurseForge\themes\
}
```
---
### Writing a plugin
## 🛠️ Building from Source
A plugin lives in its own subfolder in `plugins/<plugin-name>/` with a `manifest.json`, an `index.js`, and an optional companion `styles.css`.
### Prerequisites
`plugins/my-plugin/manifest.json`:
- Windows 10 / 11
- Python 3.10+
- Node.js 20+ (for docs)
### Build Instructions
```powershell
# Clone the repository
git clone https://github.com/your-username/BetterCurseForge.git
cd BetterCurseForge
# Build standalone executable with PyInstaller
python -m pip install pyinstaller
python -m PyInstaller BetterCurseForge.spec --noconfirm
# Output executable will be in:
# dist/BetterCurseForge.exe
```json
{
"name": "My Plugin",
"description": "Custom tweak for CurseForge",
"version": "1.0.0",
"author": "Your Name",
"main": "index.js",
"styles": "styles.css"
}
```
### Running VitePress Documentation
`plugins/my-plugin/index.js`:
```javascript
// Plugins run inside the Electron renderer with access to BetterCurseForge
const { injectCSS, patcher, settings, on, off } = BetterCurseForge;
console.log("My plugin loaded");
// Cleanup when toggled off
plugin.onDisable = () => {
console.log("My plugin disabled");
};
```
#### What plugins can do
- **UI & DOM tweaks.** Inject custom buttons, badges, links, or panels into CurseForge using standard DOM APIs. If a `styles.css` is present in the plugin folder, it is automatically injected when enabled and removed when disabled.
- **Method patching.** Intercept, modify, or override internal JavaScript functions with `BetterCurseForge.patcher.before()`, `after()`, or `instead()`.
- **Persistent settings.** Store and read user preferences across restarts using `BetterCurseForge.settings.get(key)` and `set(key, value)`.
- **Event listening.** Hook into application lifecycle events using `BetterCurseForge.on(event, callback)`.
## Building from source
You need Windows 10 or 11 and Python 3.10+. Node.js 20+ is only needed if you want to run the docs site.
```powershell
git clone https://github.com/tomasalias/BetterCurseForge.git
cd BetterCurseForge
python -m pip install pyinstaller
python -m PyInstaller BetterCurseForge.spec --noconfirm
```
The executable ends up in `dist/BetterCurseForge.exe`.
### Syncing changes from GitHub
To pull down updates made on GitHub:
```powershell
git pull origin master
```
If you have local changes that conflict:
```powershell
git stash
git pull origin master
git stash pop
```
To push your local changes to GitHub:
```powershell
git add .
git commit -m "Description of changes"
git push origin master
```
To run the VitePress docs locally:
```powershell
cd docs
@ -160,9 +172,8 @@ npm install
npm run dev
```
---
## License
## 📄 License
[MIT](LICENSE).
This project is licensed under the [MIT License](LICENSE).
CurseForge and Overwolf are trademarks of their respective owners. BetterCurseForge is an independent open-source project and is not affiliated with or endorsed by Overwolf or CurseForge.
CurseForge and Overwolf are trademarks of their respective owners. This is an independent open-source project and isn't affiliated with or endorsed by either of them.

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/icon_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -9,7 +9,7 @@ export default defineConfig({
cleanUrls: true,
head: [
['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
['link', { rel: 'icon', type: 'image/png', href: (process.env.BASE_URL || '/BetterCurseForge/') + 'logo.png' }],
['meta', { name: 'theme-color', content: '#9333ea' }],
['meta', { name: 'og:type', content: 'website' }],
['meta', { name: 'og:title', content: 'BetterCurseForge' }],
@ -18,11 +18,12 @@ export default defineConfig({
themeConfig: {
siteTitle: 'BetterCurseForge',
logo: '/logo.png',
nav: [
{ text: 'Guide', link: '/guide/installation' },
{ text: 'API', link: '/api/' },
{ text: 'GitHub', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
{ text: 'GitHub', link: 'https://github.com/tomasalias/BetterCurseForge' },
],
sidebar: [
@ -57,7 +58,7 @@ export default defineConfig({
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
{ icon: 'github', link: 'https://github.com/tomasalias/BetterCurseForge' },
],
footer: {

View file

@ -26,7 +26,7 @@ BetterCurseForge comes as a standalone desktop executable with a sleek, dark-the
Download the latest executable from the official releases:
- [Download `BetterCurseForge.exe`](https://github.com/BetterCurseForge/BetterCurseForge/releases/latest)
- [Download `BetterCurseForge.exe`](https://github.com/tomasalias/BetterCurseForge/releases/latest)
Place it in any convenient folder (e.g., Downloads, Tools, or desktop).
@ -34,7 +34,7 @@ Place it in any convenient folder (e.g., Downloads, Tools, or desktop).
Double-click `BetterCurseForge.exe` to launch the application.
![BetterCurseForge GUI](/logo.svg){width=64 style="display:block;margin:1rem auto;"}
![BetterCurseForge GUI](/logo.png){width=64 style="display:block;margin:1rem auto;"}
The application automatically:
- Detects your CurseForge installation folder from the Windows Registry.
@ -113,9 +113,9 @@ This restores `resources/app.asar` from the vanilla backup and re-verifies origi
## Auto-Repatch: Surviving CurseForge Updates
When the official CurseForge Launcher downloads an automatic update, it overwrites `app.asar` with a new version, temporarily removing client modifications.
When the official CurseForge Launcher downloads an automatic update, it overwrites `app.asar` with an unpatched version and attempts to restart.
BetterCurseForge solves this with **Auto-Repatch**:
BetterCurseForge provides an autonomous, smooth transition with **zero screen flashing**:
### Managing Auto-Repatch in the GUI
- In the BetterCurseForge desktop application, simply toggle the **Auto-Repatch on Update** button (turns green when active).
@ -125,15 +125,20 @@ BetterCurseForge solves this with **Auto-Repatch**:
# Run the background watcher daemon directly:
.\BetterCurseForge.exe watch
# Trigger the smooth splash updater GUI manually:
.\BetterCurseForge.exe --repatch-splash
# BetterCurseForge also manages watcher persistence automatically on patch/unpatch:
.\BetterCurseForge.exe patch # Applies patch and activates watcher autostart
.\BetterCurseForge.exe unpatch # Restores vanilla client and stops watcher
```
### How Auto-Repatch Works
1. **Background Watcher**: Runs silently with 0% CPU consumption via Windows directory event notifications.
2. **Global Mutex Lock**: Uses kernel named mutexes to guarantee single-instance execution and avoid race conditions.
3. **Automatic Re-Patching**: Whenever CurseForge updates, the watcher waits for the installer to finish, reapplies BetterCurseForge modifications offline in 2–3 seconds, synchronizes executable signatures, and relaunches the modded client seamlessly without popping command prompt windows.
1. **Background Watcher**: Runs silently with 0% CPU consumption via Windows directory event notifications and heartbeat checks.
2. **Active Vanilla Interceptor**: When an update or unpatched state is detected, the patcher polls every 150ms using Win32 kernel process enumeration. If the installer launches `CurseForge.exe`, it is killed within 50ms before any window or frame can appear.
3. **Dedicated Splash Updater GUI**: A modern, dark-themed `#121214` card appears on screen displaying the application icon, a purple `AUTO-UPDATE` badge, an animated progress bar, and real-time status messages while extracting, unlocking themes, removing ads, and repacking.
4. **Offline Zero-Delay Engine**: Offline ASAR extraction and repacking complete in 2–3 seconds using CurseForge's built-in Node v24 runtime (`ELECTRON_RUN_AS_NODE=1`), synchronizing the `OWEASARSIG` hash directly inside `CurseForge.exe`.
5. **Seamless Relaunch**: The patched BetterCurseForge client opens automatically, and the splash window gracefully closes.
---

View file

@ -5,13 +5,16 @@ hero:
name: "BetterCurseForge"
text: "Client mod for CurseForge Launcher"
tagline: "Ad-free experience, custom themes, unlocked premium skins, and plugins."
image:
src: /logo.png
alt: BetterCurseForge
actions:
- theme: brand
text: Get Started
link: /guide/installation
- theme: alt
text: View on GitHub
link: https://github.com/BetterCurseForge/BetterCurseForge
link: https://github.com/tomasalias/BetterCurseForge
features:
- icon: 🚫
@ -21,8 +24,8 @@ features:
title: Client Themes & Free Skins
details: Integrated directly into Settings -> Themes with live hover previews, theme folder shortcuts, and all native CurseForge Premium themes unlocked for free.
- icon: 🔄
title: Auto-Repatch
details: Survives launcher updates effortlessly. The automated background task detects CurseForge updates and silently re-patches the client.
title: Auto-Repatch & Smooth Updates
details: Survives launcher updates effortlessly. The active interceptor suppresses unpatched windows, and a sleek splash GUI displays live progress while repatching in seconds.
- icon: 🖥️
title: Modern GUI & CLI
details: Includes an intuitive dark-themed desktop application with one-click actions, as well as a full command-line interface for power users.

BIN
docs/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,20 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="purpleGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#c084fc" />
<stop offset="50%" stop-color="#9333ea" />
<stop offset="100%" stop-color="#6b21a8" />
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Background rounded rect -->
<rect x="5" y="5" width="90" height="90" rx="22" fill="#14141d" stroke="url(#purpleGrad)" stroke-width="3" />
<!-- Stylized Flame / Anvil / BCF Monogram -->
<path d="M 50 18 C 55 30 65 36 68 46 C 72 58 64 74 50 78 C 36 74 28 58 32 46 C 35 36 45 30 50 18 Z" fill="url(#purpleGrad)" filter="url(#glow)" />
<path d="M 50 38 C 53 45 58 48 59 54 C 61 60 57 68 50 70 C 43 68 39 60 41 54 C 42 48 47 45 50 38 Z" fill="#ffffff" opacity="0.9" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512">
<image href="logo.png" xlink:href="logo.png" x="0" y="0" width="512" height="512" />
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 225 B

View file

@ -22,10 +22,12 @@ RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex"
SPLASH_MUTEX_NAME = "BetterCurseForgeSplashMutex"
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
PERMANENT_BCF = PERMANENT_DIR / "bettercf"
PERMANENT_VENDOR = PERMANENT_DIR / "vendor"
PERMANENT_ASSETS = PERMANENT_DIR / "assets"
class GlobalNamedMutex:
def __init__(self, name: str, timeout_ms: int = 5000):
@ -111,13 +113,79 @@ def get_curseforge_dir() -> Path | None:
return p
return None
def get_process_pids(image_name: str) -> list[int]:
"""
Ultra-fast process lookup via Win32 Toolhelp32Snapshot (<1ms, zero subprocess overhead).
"""
if os.name != "nt":
return []
try:
import ctypes
from ctypes import wintypes
TH32CS_SNAPPROCESS = 0x00000002
class PROCESSENTRY32(ctypes.Structure):
_fields_ = [
('dwSize', wintypes.DWORD),
('cntUsage', wintypes.DWORD),
('th32ProcessID', wintypes.DWORD),
('th32DefaultHeapID', ctypes.c_size_t),
('th32ModuleID', wintypes.DWORD),
('cntThreads', wintypes.DWORD),
('th32ParentProcessID', wintypes.DWORD),
('pcPriClassBase', ctypes.c_long),
('dwFlags', wintypes.DWORD),
('szExeFile', ctypes.c_char * 260)
]
kernel32 = ctypes.windll.kernel32
h_snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if h_snap == -1 or not h_snap:
return []
pe = PROCESSENTRY32()
pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
pids = []
target = image_name.lower()
if kernel32.Process32First(h_snap, ctypes.byref(pe)):
while True:
name = pe.szExeFile.decode('mbcs', errors='ignore').lower()
if name == target:
pids.append(pe.th32ProcessID)
if not kernel32.Process32Next(h_snap, ctypes.byref(pe)):
break
kernel32.CloseHandle(h_snap)
return pids
except Exception:
return []
def kill_process_by_name(image_name: str) -> int:
"""
Silently and immediately terminates all matching processes via TerminateProcess (<1ms).
"""
if os.name != "nt":
return 0
try:
import ctypes
PROCESS_TERMINATE = 0x0001
kernel32 = ctypes.windll.kernel32
pids = get_process_pids(image_name)
killed = 0
for pid in pids:
h_proc = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
if h_proc:
if kernel32.TerminateProcess(h_proc, 1):
killed += 1
kernel32.CloseHandle(h_proc)
return killed
except Exception:
return 0
def kill_curseforge():
kill_process_by_name("curseforge.exe")
try:
subprocess.run(
["taskkill", "/f", "/im", "CurseForge.exe"],
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
)
time.sleep(0.5)
time.sleep(0.3)
except Exception:
pass
@ -247,6 +315,20 @@ def find_vendor_dir() -> Path | None:
return c
return None
def find_assets_dir() -> Path | None:
candidates = [
Path(getattr(sys, "_MEIPASS", "")) / "assets" if hasattr(sys, "_MEIPASS") else None,
Path(sys.executable).parent / "assets" if getattr(sys, "frozen", False) else None,
PERMANENT_ASSETS,
Path(__file__).parent / "assets",
Path(__file__).parent.parent / "assets",
Path.cwd() / "assets",
]
for c in candidates:
if c and c.exists() and c.is_dir():
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
@ -726,6 +808,8 @@ def unpatch_curseforge(log_callback=print) -> bool:
# ═══════════════════════════════════════════════════════════════════════════════
# AUTO-REPATCH WATCHER & PERSISTENCE ENGINE
def is_curseforge_running() -> bool:
if os.name == "nt":
return len(get_process_pids("curseforge.exe")) > 0
try:
out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
@ -736,22 +820,94 @@ def is_curseforge_running() -> bool:
return False
def is_updater_running() -> bool:
"""
Checks if a CurseForge or Overwolf updater/installer process is currently running.
Strictly scoped to avoid false positives from unrelated apps (Discord, Logitech, etc.).
"""
if os.name == "nt":
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):
import ctypes
from ctypes import wintypes
TH32CS_SNAPPROCESS = 0x00000002
class PROCESSENTRY32(ctypes.Structure):
_fields_ = [
('dwSize', wintypes.DWORD),
('cntUsage', wintypes.DWORD),
('th32ProcessID', wintypes.DWORD),
('th32DefaultHeapID', ctypes.c_size_t),
('th32ModuleID', wintypes.DWORD),
('cntThreads', wintypes.DWORD),
('th32ParentProcessID', wintypes.DWORD),
('pcPriClassBase', ctypes.c_long),
('dwFlags', wintypes.DWORD),
('szExeFile', ctypes.c_char * 260)
]
kernel32 = ctypes.windll.kernel32
h_snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if h_snap != -1 and h_snap:
pe = PROCESSENTRY32()
pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
found = False
if kernel32.Process32First(h_snap, ctypes.byref(pe)):
while True:
name = pe.szExeFile.decode('mbcs', errors='ignore').lower()
# Strictly match CurseForge or Overwolf installer/updater processes
if ('curseforge' in name and any(k in name for k in ('update', 'setup', 'install'))) or \
('overwolf' in name and any(k in name for k in ('installer', 'updater', 'setup'))):
found = True
break
if not kernel32.Process32Next(h_snap, ctypes.byref(pe)):
break
kernel32.CloseHandle(h_snap)
if found:
return True
except Exception:
pass
return False
def is_splash_running() -> bool:
if os.name != "nt":
return False
try:
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, SPLASH_MUTEX_NAME)
if h:
kernel32.CloseHandle(h)
return True
return False
except Exception:
return False
def launch_splash_updater(pid_to_wait: int | None = None) -> bool:
if is_splash_running():
return False
perm_exe = ensure_permanent_installation()
args = ["--repatch-splash"]
if pid_to_wait:
args.append(str(pid_to_wait))
if perm_exe.exists() and perm_exe.suffix.lower() == ".exe":
cmd = [str(perm_exe.resolve())] + args
elif getattr(sys, "frozen", False):
cmd = [str(Path(sys.executable).resolve())] + args
else:
python_exe = Path(sys.executable).parent / "pythonw.exe"
if not python_exe.exists():
python_exe = Path(sys.executable)
cmd = [str(python_exe.resolve()), str(Path(__file__).parent.resolve() / "patcher_gui.py")] + args
try:
creation_flags = 0x00000008 | 0x00000200 if os.name == "nt" else 0 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
subprocess.Popen(cmd, creationflags=creation_flags)
return True
except Exception:
return False
def ensure_permanent_installation() -> Path:
@ -775,6 +931,13 @@ def ensure_permanent_installation() -> Path:
shutil.rmtree(PERMANENT_VENDOR, ignore_errors=True)
shutil.copytree(src_vendor, PERMANENT_VENDOR)
src_assets = find_assets_dir()
if src_assets and src_assets.exists():
if PERMANENT_ASSETS.resolve() != src_assets.resolve():
if PERMANENT_ASSETS.exists():
shutil.rmtree(PERMANENT_ASSETS, ignore_errors=True)
shutil.copytree(src_assets, PERMANENT_ASSETS)
if getattr(sys, "frozen", False):
curr_exe = Path(sys.executable).resolve()
if curr_exe != PERMANENT_EXE.resolve():
@ -887,10 +1050,12 @@ def stop_background_watcher():
except Exception:
pass
def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) -> bool:
def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, intercept_unpatched: bool = True) -> bool:
"""
Waits for the CurseForge updater/installer to finish writing files.
Ensures resources/app.asar is present, fully written, and readable.
Actively intercepts and kills any unpatched CurseForge.exe instances launched
by the installer before their window can render on screen.
"""
import time
start = time.time()
@ -899,7 +1064,15 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) ->
stable_checks = 0
while time.time() - start < timeout:
time.sleep(1.5)
# Check in 150ms slices (total ~1.5s per outer cycle) to actively silence unpatched CurseForge
for _ in range(10):
if intercept_unpatched:
pids = get_process_pids("curseforge.exe")
if pids:
kill_process_by_name("curseforge.exe")
if log_fn:
log_fn("Silencing vanilla launcher in background...")
time.sleep(0.15)
if is_updater_running():
stable_checks = 0
@ -936,9 +1109,12 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) ->
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.
Spawns the dedicated Splash Updater GUI for a smooth visual transition,
or falls back to headless repatch if UI cannot launch.
"""
if launch_splash_updater(pid_to_wait):
return True
import time
if log_fn is None:
log_dir = get_user_data_dir()
@ -961,27 +1137,19 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
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:
pids = get_process_pids("curseforge.exe")
if pid_to_wait not in pids:
break
except Exception:
break
time.sleep(0.5)
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn)
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn, intercept_unpatched=True)
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)
@ -1036,30 +1204,41 @@ def run_watcher_loop():
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)
last_splash_launch = 0.0
wlog("Applying BetterCurseForge patch...")
def check_and_repatch(trigger_reason: str):
nonlocal last_splash_launch
now = time.time()
if is_splash_running():
return
# ONLY trigger if CurseForge is genuinely unpatched
if not is_curseforge_patched(cf_dir):
if now - last_splash_launch < 15.0:
return
last_splash_launch = now
wlog(f"CurseForge unpatched state detected ({trigger_reason}). Launching Splash Updater...")
ok = launch_splash_updater()
if not ok:
wlog("Could not launch Splash Updater; falling back to background patch.")
was_running = is_curseforge_running()
was_intercepted = [False]
def on_fb_log(msg: str):
if "silenc" in msg.lower():
was_intercepted[0] = True
wlog(msg)
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=on_fb_log, intercept_unpatched=True)
if settled:
kill_curseforge()
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...")
if was_running or was_intercepted[0]:
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
else:
wlog("Failed to re-patch CurseForge after update.")

View file

@ -9,7 +9,7 @@ and providing zero-timer automatic re-patching after updates.
import os
import sys
if os.name == "nt":
if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher"):
if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"):
import ctypes
ctypes.windll.kernel32.AttachConsole(-1)
if sys.stdout is not None:
@ -25,6 +25,12 @@ from tkinter import ttk, messagebox, scrolledtext
from pathlib import Path
from datetime import datetime
try:
from PIL import Image, ImageTk
HAS_PIL = True
except ImportError:
HAS_PIL = False
# Import patch core logic
try:
from . import patch_core
@ -33,6 +39,20 @@ except ImportError:
VERSION = "1.0.0"
def find_asset(filename: str) -> Path | None:
candidates = [
Path(getattr(sys, "_MEIPASS", "")) / "assets" / filename if hasattr(sys, "_MEIPASS") else None,
Path(getattr(sys, "_MEIPASS", "")) / filename if hasattr(sys, "_MEIPASS") else None,
Path(sys.executable).parent / "assets" / filename if getattr(sys, "frozen", False) else None,
getattr(patch_core, "PERMANENT_ASSETS", Path()) / filename,
Path(__file__).parent.parent / "assets" / filename,
Path.cwd() / "assets" / filename,
]
for c in candidates:
if c and c.exists():
return c
return None
# ═══════════════════════════════════════════════════════════════════════════════
# COLOR PALETTE (Modern Discord / Vencord Dark Theme)
# ═══════════════════════════════════════════════════════════════════════════════
@ -71,11 +91,58 @@ class BetterCFApp:
self.install_dir = None
self.is_working = False
self._icon_photo = None
self._header_icon_photo = None
self._configure_styles()
self._setup_window_icon()
self._build_ui()
self._refresh_status()
def _setup_window_icon(self):
ico_path = find_asset("icon.ico")
if ico_path and os.name == "nt":
try:
self.root.iconbitmap(str(ico_path.resolve()))
self.root.iconbitmap(default=str(ico_path.resolve()))
except Exception:
pass
try:
import ctypes
user32 = ctypes.windll.user32
GA_ROOT = 2
WM_SETICON = 0x0080
IMAGE_ICON = 1
LR_LOADFROMFILE = 0x0010
self.root.update_idletasks()
inner_hwnd = self.root.winfo_id()
root_hwnd = user32.GetAncestor(inner_hwnd, GA_ROOT) or inner_hwnd
self._win32_icon_sm = user32.LoadImageW(None, str(ico_path.resolve()), IMAGE_ICON, 16, 16, LR_LOADFROMFILE)
self._win32_icon_bg = user32.LoadImageW(None, str(ico_path.resolve()), IMAGE_ICON, 32, 32, LR_LOADFROMFILE)
for h in (inner_hwnd, root_hwnd):
if self._win32_icon_sm:
user32.SendMessageW(h, WM_SETICON, 0, self._win32_icon_sm)
if self._win32_icon_bg:
user32.SendMessageW(h, WM_SETICON, 1, self._win32_icon_bg)
except Exception:
pass
elif os.name != "nt":
png_path = find_asset("icon_512.png") or find_asset("icon.png")
if png_path:
try:
if HAS_PIL:
im = Image.open(png_path)
self._icon_photo = ImageTk.PhotoImage(im.resize((64, 64), Image.Resampling.LANCZOS))
self.root.iconphoto(True, self._icon_photo)
else:
self._icon_photo = tk.PhotoImage(file=str(png_path))
self.root.iconphoto(True, self._icon_photo)
except Exception:
pass
def _configure_styles(self):
style = ttk.Style()
style.theme_use("clam")
@ -97,8 +164,19 @@ class BetterCFApp:
title_frame = tk.Frame(header, bg=COLORS["bg_card"])
title_frame.pack(side="left")
# Header icon thumbnail
png_path = find_asset("icon_512.png") or find_asset("icon.png")
if png_path and HAS_PIL:
try:
header_img = Image.open(png_path).resize((32, 32), Image.Resampling.LANCZOS)
self._header_icon_photo = ImageTk.PhotoImage(header_img)
icon_lbl = tk.Label(title_frame, image=self._header_icon_photo, bg=COLORS["bg_card"])
icon_lbl.pack(side="left", padx=(0, 10))
except Exception:
pass
title_lbl = tk.Label(
title_frame, text="⬡ BetterCurseForge", font=("Segoe UI", 16, "bold"),
title_frame, text="BetterCurseForge", font=("Segoe UI", 16, "bold"),
bg=COLORS["bg_card"], fg=COLORS["purple_glow"]
)
title_lbl.pack(side="left")
@ -391,6 +469,262 @@ class BetterCFApp:
self.log(f"BetterCurseForge Patcher v{VERSION} ready.")
self.root.mainloop()
# ═══════════════════════════════════════════════════════════════════════════════
# DEDICATED SPLASH UPDATER GUI
# ═══════════════════════════════════════════════════════════════════════════════
class SplashUpdaterWindow:
def __init__(self, target_pid: int | None = None):
self.target_pid = target_pid
self.root = tk.Tk()
self.root.title("BetterCurseForge Updater")
width, height = 460, 215
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
x = (screen_w - width) // 2
y = (screen_h - height) // 2
self.root.geometry(f"{width}x{height}+{x}+{y}")
self.root.overrideredirect(True)
self.root.attributes("-topmost", True)
self.root.configure(bg=COLORS["bg"])
self._icon_photo = None
self._drag_x = 0
self._drag_y = 0
self._setup_window_icon()
self._build_ui()
def _setup_window_icon(self):
ico_path = find_asset("icon.ico")
if ico_path and os.name == "nt":
try:
self.root.iconbitmap(str(ico_path.resolve()))
self.root.iconbitmap(default=str(ico_path.resolve()))
except Exception:
pass
def _build_ui(self):
card = tk.Frame(
self.root, bg=COLORS["bg_card"],
highlightbackground=COLORS["purple_dark"], highlightthickness=1,
padx=20, pady=18
)
card.pack(fill="both", expand=True)
def start_drag(e):
self._drag_x = e.x
self._drag_y = e.y
def do_drag(e):
x = self.root.winfo_x() + (e.x - self._drag_x)
y = self.root.winfo_y() + (e.y - self._drag_y)
self.root.geometry(f"+{x}+{y}")
card.bind("<Button-1>", start_drag)
card.bind("<B1-Motion>", do_drag)
# Header
header = tk.Frame(card, bg=COLORS["bg_card"])
header.pack(fill="x")
header.bind("<Button-1>", start_drag)
header.bind("<B1-Motion>", do_drag)
png_path = find_asset("icon_512.png") or find_asset("icon.png")
if png_path and HAS_PIL:
try:
im = Image.open(png_path).resize((44, 44), Image.Resampling.LANCZOS)
self._icon_photo = ImageTk.PhotoImage(im)
icon_lbl = tk.Label(header, image=self._icon_photo, bg=COLORS["bg_card"])
icon_lbl.pack(side="left", padx=(0, 14))
icon_lbl.bind("<Button-1>", start_drag)
icon_lbl.bind("<B1-Motion>", do_drag)
except Exception:
pass
text_col = tk.Frame(header, bg=COLORS["bg_card"])
text_col.pack(side="left", fill="both", expand=True)
text_col.bind("<Button-1>", start_drag)
text_col.bind("<B1-Motion>", do_drag)
title_row = tk.Frame(text_col, bg=COLORS["bg_card"])
title_row.pack(anchor="w")
title_row.bind("<Button-1>", start_drag)
title_row.bind("<B1-Motion>", do_drag)
t_lbl = tk.Label(
title_row, text="BetterCurseForge", font=("Segoe UI", 13, "bold"),
fg=COLORS["text"], bg=COLORS["bg_card"]
)
t_lbl.pack(side="left")
t_lbl.bind("<Button-1>", start_drag)
t_lbl.bind("<B1-Motion>", do_drag)
badge = tk.Frame(
title_row, bg="#261b3d", highlightbackground=COLORS["purple_glow"],
highlightthickness=1, padx=6, pady=1
)
badge.pack(side="left", padx=(10, 0))
b_lbl = tk.Label(
badge, text="AUTO-UPDATE", font=("Segoe UI", 8, "bold"),
fg="#c4b5fd", bg="#261b3d"
)
b_lbl.pack()
sub_lbl = tk.Label(
text_col, text="CurseForge update detected. Applying enhancements...",
font=("Segoe UI", 9), fg=COLORS["text_dim"], bg=COLORS["bg_card"]
)
sub_lbl.pack(anchor="w", pady=(3, 0))
sub_lbl.bind("<Button-1>", start_drag)
sub_lbl.bind("<B1-Motion>", do_drag)
# Progress bar
style = ttk.Style()
style.theme_use("clam")
style.configure(
"Splash.Horizontal.TProgressbar",
troughcolor="#1e1f22",
background=COLORS["purple"],
bordercolor="#1e1f22",
lightcolor=COLORS["purple"],
darkcolor=COLORS["purple"]
)
self.pbar = ttk.Progressbar(
card, style="Splash.Horizontal.TProgressbar",
mode="indeterminate", length=410
)
self.pbar.pack(fill="x", pady=(16, 10))
self.pbar.start(12)
# Status labels
self.status_lbl = tk.Label(
card, text="Waiting for CurseForge installer to finish...",
font=("Segoe UI", 9, "bold"), fg=COLORS["text"], bg=COLORS["bg_card"], anchor="w"
)
self.status_lbl.pack(fill="x")
self.detail_lbl = tk.Label(
card, text="Silencing vanilla client in background...",
font=("Segoe UI", 8), fg=COLORS["text_muted"], bg=COLORS["bg_card"], anchor="w"
)
self.detail_lbl.pack(fill="x", pady=(2, 0))
def set_status(self, main_text: str, detail_text: str = "", status_type: str = "normal"):
color = COLORS["text"]
if status_type == "success":
color = COLORS["green"]
elif status_type == "error":
color = COLORS["red"]
elif status_type == "warn":
color = COLORS["yellow"]
self.status_lbl.configure(text=main_text, fg=color)
if detail_text:
self.detail_lbl.configure(text=detail_text)
def run(self):
threading.Thread(target=self._worker, daemon=True).start()
self.root.mainloop()
def _worker(self):
import time
with patch_core.GlobalNamedMutex(patch_core.SPLASH_MUTEX_NAME, timeout_ms=500) as splash_lock:
if not splash_lock.acquired:
self.root.after(0, self.root.destroy)
return
try:
cf_dir = patch_core.get_curseforge_dir()
if not cf_dir:
self.root.after(0, self.set_status, "CurseForge not found!", "Please reinstall CurseForge.", "error")
time.sleep(3.0)
return
# Early guard: if no target_pid, CurseForge is already patched, and no updater is running, exit immediately
if not self.target_pid and patch_core.is_curseforge_patched(cf_dir) and not patch_core.is_updater_running():
self.root.after(0, self.root.destroy)
return
was_running = patch_core.is_curseforge_running() or (self.target_pid is not None)
was_intercepted = [False]
if self.target_pid:
self.root.after(0, self.set_status, "Waiting for previous instance to exit...", f"CurseForge PID {self.target_pid}")
for _ in range(30):
pids = patch_core.get_process_pids("curseforge.exe")
if self.target_pid not in pids:
break
time.sleep(0.5)
self.root.after(0, self.set_status, "Waiting for CurseForge installer...", "Silencing vanilla launcher in background...")
def on_settle_log(msg: str):
if "running" in msg.lower():
self.root.after(0, self.set_status, "CurseForge installer running...", "Silencing vanilla launcher in background...")
elif "settle" in msg.lower() or "mb" in msg.lower():
self.root.after(0, self.set_status, "Finalizing update files...", msg)
elif "silenc" in msg.lower():
was_intercepted[0] = True
self.root.after(0, self.set_status, "Silenced vanilla launcher in background...", "Preventing unpatched window display...")
settled = patch_core.wait_for_files_settled(cf_dir, timeout=90.0, log_fn=on_settle_log, intercept_unpatched=True)
if not settled:
self.root.after(0, self.set_status, "Installer wait timed out.", "Will attempt patch now...", "warn")
patch_core.kill_curseforge()
self.root.after(0, self.set_status, "Extracting application archive...", "Unpacking update files...")
def on_patch_log(msg: str):
if "Extracting" in msg:
self.root.after(0, self.set_status, "Extracting application archive...", "Unpacking update files...")
elif "Unlocked native premium" in msg:
self.root.after(0, self.set_status, "Unlocking native premium themes...", "Patching client theme routes...")
elif "premium theme state" in msg or "subscription plan" in msg:
self.root.after(0, self.set_status, "Enabling theme engine...", "Bypassing subscription restrictions...")
elif "Injected zero-ad" in msg:
self.root.after(0, self.set_status, "Injecting ad-block rules...", "Removing sponsored banners and ad containers...")
elif "Bundled BetterCurseForge runtime" in msg:
self.root.after(0, self.set_status, "Bundling BetterCurseForge runtime...", "Injecting core modules...")
elif "main-process hooks" in msg:
self.root.after(0, self.set_status, "Hooking main process...", "Configuring network filter and IPC...")
elif "Repacking app.asar" in msg:
self.root.after(0, self.set_status, "Repacking application archive...", "Rebuilding app.asar package...")
elif "integrity signature" in msg:
self.root.after(0, self.set_status, "Updating security signatures...", "Synchronizing OWEASARSIG header hash...")
elif "successfully patched" in msg:
self.root.after(0, self.set_status, "✓ Complete! Launching BetterCurseForge...", "All enhancements applied successfully.", "success")
success = False
for attempt in range(1, 4):
success = patch_core.patch_curseforge(log_callback=on_patch_log)
if success:
break
self.root.after(0, self.set_status, f"Retrying patch ({attempt+1}/3)...", "Resolving temporary file lock...", "warn")
time.sleep(2.0)
if success:
should_launch = was_running or was_intercepted[0] or (self.target_pid is not None)
if should_launch:
self.root.after(0, self.set_status, "✓ Complete! Launching BetterCurseForge...", "Enjoy your ad-free experience!", "success")
exe = cf_dir / "CurseForge.exe"
if exe.exists():
creation_flags = 0x00000008 | 0x00000200 if os.name == "nt" else 0 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
subprocess.Popen([str(exe)], cwd=str(cf_dir), creationflags=creation_flags)
else:
self.root.after(0, self.set_status, "✓ Complete! BetterCurseForge is updated.", "CurseForge updated in background.", "success")
time.sleep(0.9)
else:
self.root.after(0, self.set_status, "Failed to apply patch.", "Check watcher.log for details.", "error")
time.sleep(3.0)
except Exception as e:
self.root.after(0, self.set_status, "Error during update.", str(e), "error")
time.sleep(3.0)
finally:
self.root.after(0, self.root.destroy)
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN ENTRY POINT
# ═══════════════════════════════════════════════════════════════════════════════
@ -399,24 +733,28 @@ if __name__ == "__main__":
try:
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
if arg == "--watch":
if arg in ("--watch", "watch"):
patch_core.run_watcher_loop()
sys.exit(0)
elif arg == "--wait-for-update":
elif arg in ("--repatch-splash", "--splash", "splash", "--wait-for-update", "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)
cf = patch_core.get_curseforge_dir()
if not pid and cf and patch_core.is_curseforge_patched(cf) and not patch_core.is_updater_running():
sys.exit(0)
elif arg == "--ensure-watcher":
splash = SplashUpdaterWindow(pid)
splash.run()
sys.exit(0)
elif arg in ("--ensure-watcher", "ensure-watcher"):
if not patch_core.is_watcher_running():
patch_core.start_background_watcher()
sys.exit(0)
elif arg == "patch":
elif arg in ("--patch", "patch"):
patch_core.patch_curseforge()
sys.exit(0)
elif arg == "unpatch":
elif arg in ("--unpatch", "unpatch"):
patch_core.unpatch_curseforge()
sys.exit(0)
elif arg == "status":
elif arg in ("--status", "status"):
cf = patch_core.get_curseforge_dir()
print("CurseForge:", cf)
print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False)
@ -428,7 +766,7 @@ if __name__ == "__main__":
except SystemExit:
pass
except Exception as e:
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"):
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"):
try:
log_dir = patch_core.get_user_data_dir()
with open(log_dir / "watcher.log", "a", encoding="utf-8") as f: