Compare commits

..

No commits in common. "c66cd9484a0683e4b0ee2adb32375040a2296fca" and "46dc6dc849e1a0094c757777e25196f7e2c596ee" have entirely different histories.

14 changed files with 201 additions and 793 deletions

View file

@ -1,65 +0,0 @@
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,9 +36,3 @@ desktop.ini
.idea/ .idea/
*.swp *.swp
*.swo *.swo
# Local utility tools
tools/
update_icon.bat
update_icon.py

View file

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

213
README.md
View file

@ -1,73 +1,114 @@
# BetterCurseForge # BetterCurseForge
<img src="assets/icon_512.png" alt="BetterCurseForge logo" width="96" height="96" /> <div align="center">
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. <img src="docs/public/logo.svg" alt="BetterCurseForge Logo" width="128" height="128" />
### The modern, lightweight client modification for CurseForge Launcher.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![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)
## What it does **Zero Ads • Custom Themes • Unlocked Skins • Extensible Plugins • Autonomous Auto-Repatch**
- **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. </div>
- **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 ---
1. Download `BetterCurseForge.exe` from [Releases](https://github.com/tomasalias/BetterCurseForge/releases). ## ✨ Features
2. Run it and click **Patch CurseForge**.
3. Start CurseForge as usual.
It also works from PowerShell or Command Prompt: - 🚫 **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:
```bash ```bash
# show patch and watcher status # Check current patch and watcher status
.\BetterCurseForge.exe status .\BetterCurseForge.exe status
# apply the patch # Apply BetterCurseForge patch
.\BetterCurseForge.exe patch .\BetterCurseForge.exe patch
# go back to stock CurseForge # Restore original vanilla CurseForge
.\BetterCurseForge.exe unpatch .\BetterCurseForge.exe unpatch
# run the background watcher in the foreground # Run background watcher daemon directly
.\BetterCurseForge.exe watch .\BetterCurseForge.exe watch
# open the splash updater by hand
.\BetterCurseForge.exe --repatch-splash
``` ```
## How the 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: ## 🔄 How Auto-Repatch Works
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. 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:
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. ```mermaid
flowchart TD
Two named Windows mutexes, `BetterCurseForgePatchMutex` and `BetterCurseForgeSplashMutex`, stop two patch runs or two splash windows from happening at the same time. A[CurseForge Launcher Update] -->|Installer downloads update| B[CurseForge Quitting / Replacing Files]
B -->|Watcher Detects Update| C[Auto-Repatch Triggered]
## Themes and plugins C -->|Wait for Installer Files to Settle| D[Settle Verified]
D -->|Offline Extraction & Patch <3s| E[Dist & BetterCF Injected]
Everything user-facing lives in `%APPDATA%\CurseForge\BetterCurseForge\`: E -->|Recalculate ASAR SHA-256| F[Synchronize OWEASARSIG in CurseForge.exe]
F -->|Autonomous Relaunch| G[Modded CurseForge Launcher Ready!]
```
themes/
purple-night.css
purple-night.json
plugins/
compact-mode/
manifest.json
index.js
``` ```
The GUI has **Open Themes Folder** and **Open Plugins Folder** buttons if you don't want to dig for it. 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.
### Writing a theme ---
A theme is a CSS file plus a JSON manifest with the same name. For example, `themes/my-theme.json`: ## 🎨 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`:
```json ```json
{ {
@ -79,8 +120,6 @@ A theme is a CSS file plus a JSON manifest with the same name. For example, `the
} }
``` ```
and `themes/my-theme.css`:
```css ```css
:root { :root {
--surface-brand-brand: #10b981 !important; --surface-brand-brand: #10b981 !important;
@ -88,83 +127,32 @@ and `themes/my-theme.css`:
} }
``` ```
### Writing a plugin ---
A plugin lives in its own subfolder in `plugins/<plugin-name>/` with a `manifest.json`, an `index.js`, and an optional companion `styles.css`. ## 🛠️ Building from Source
`plugins/my-plugin/manifest.json`: ### Prerequisites
```json - Windows 10 / 11
{ - Python 3.10+
"name": "My Plugin", - Node.js 20+ (for docs)
"description": "Custom tweak for CurseForge",
"version": "1.0.0",
"author": "Your Name",
"main": "index.js",
"styles": "styles.css"
}
```
`plugins/my-plugin/index.js`: ### Build Instructions
```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 ```powershell
git clone https://github.com/tomasalias/BetterCurseForge.git # Clone the repository
git clone https://github.com/your-username/BetterCurseForge.git
cd BetterCurseForge cd BetterCurseForge
# Build standalone executable with PyInstaller
python -m pip install pyinstaller python -m pip install pyinstaller
python -m PyInstaller BetterCurseForge.spec --noconfirm python -m PyInstaller BetterCurseForge.spec --noconfirm
# Output executable will be in:
# dist/BetterCurseForge.exe
``` ```
The executable ends up in `dist/BetterCurseForge.exe`. ### Running VitePress Documentation
### 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 ```powershell
cd docs cd docs
@ -172,8 +160,9 @@ npm install
npm run dev npm run dev
``` ```
## License ---
[MIT](LICENSE). ## 📄 License
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. 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -9,7 +9,7 @@ export default defineConfig({
cleanUrls: true, cleanUrls: true,
head: [ head: [
['link', { rel: 'icon', type: 'image/png', href: (process.env.BASE_URL || '/BetterCurseForge/') + 'logo.png' }], ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
['meta', { name: 'theme-color', content: '#9333ea' }], ['meta', { name: 'theme-color', content: '#9333ea' }],
['meta', { name: 'og:type', content: 'website' }], ['meta', { name: 'og:type', content: 'website' }],
['meta', { name: 'og:title', content: 'BetterCurseForge' }], ['meta', { name: 'og:title', content: 'BetterCurseForge' }],
@ -18,12 +18,11 @@ export default defineConfig({
themeConfig: { themeConfig: {
siteTitle: 'BetterCurseForge', siteTitle: 'BetterCurseForge',
logo: '/logo.png',
nav: [ nav: [
{ text: 'Guide', link: '/guide/installation' }, { text: 'Guide', link: '/guide/installation' },
{ text: 'API', link: '/api/' }, { text: 'API', link: '/api/' },
{ text: 'GitHub', link: 'https://github.com/tomasalias/BetterCurseForge' }, { text: 'GitHub', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
], ],
sidebar: [ sidebar: [
@ -58,7 +57,7 @@ export default defineConfig({
], ],
socialLinks: [ socialLinks: [
{ icon: 'github', link: 'https://github.com/tomasalias/BetterCurseForge' }, { icon: 'github', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
], ],
footer: { 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 the latest executable from the official releases:
- [Download `BetterCurseForge.exe`](https://github.com/tomasalias/BetterCurseForge/releases/latest) - [Download `BetterCurseForge.exe`](https://github.com/BetterCurseForge/BetterCurseForge/releases/latest)
Place it in any convenient folder (e.g., Downloads, Tools, or desktop). 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. Double-click `BetterCurseForge.exe` to launch the application.
![BetterCurseForge GUI](/logo.png){width=64 style="display:block;margin:1rem auto;"} ![BetterCurseForge GUI](/logo.svg){width=64 style="display:block;margin:1rem auto;"}
The application automatically: The application automatically:
- Detects your CurseForge installation folder from the Windows Registry. - 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 ## Auto-Repatch: Surviving CurseForge Updates
When the official CurseForge Launcher downloads an automatic update, it overwrites `app.asar` with an unpatched version and attempts to restart. When the official CurseForge Launcher downloads an automatic update, it overwrites `app.asar` with a new version, temporarily removing client modifications.
BetterCurseForge provides an autonomous, smooth transition with **zero screen flashing**: BetterCurseForge solves this with **Auto-Repatch**:
### Managing Auto-Repatch in the GUI ### Managing Auto-Repatch in the GUI
- In the BetterCurseForge desktop application, simply toggle the **Auto-Repatch on Update** button (turns green when active). - In the BetterCurseForge desktop application, simply toggle the **Auto-Repatch on Update** button (turns green when active).
@ -125,20 +125,15 @@ BetterCurseForge provides an autonomous, smooth transition with **zero screen fl
# Run the background watcher daemon directly: # Run the background watcher daemon directly:
.\BetterCurseForge.exe watch .\BetterCurseForge.exe watch
# Trigger the smooth splash updater GUI manually:
.\BetterCurseForge.exe --repatch-splash
# BetterCurseForge also manages watcher persistence automatically on patch/unpatch: # BetterCurseForge also manages watcher persistence automatically on patch/unpatch:
.\BetterCurseForge.exe patch # Applies patch and activates watcher autostart .\BetterCurseForge.exe patch # Applies patch and activates watcher autostart
.\BetterCurseForge.exe unpatch # Restores vanilla client and stops watcher .\BetterCurseForge.exe unpatch # Restores vanilla client and stops watcher
``` ```
### How Auto-Repatch Works ### How Auto-Repatch Works
1. **Background Watcher**: Runs silently with 0% CPU consumption via Windows directory event notifications and heartbeat checks. 1. **Background Watcher**: Runs silently with 0% CPU consumption via Windows directory event notifications.
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. 2. **Global Mutex Lock**: Uses kernel named mutexes to guarantee single-instance execution and avoid race conditions.
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. 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.
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,16 +5,13 @@ hero:
name: "BetterCurseForge" name: "BetterCurseForge"
text: "Client mod for CurseForge Launcher" text: "Client mod for CurseForge Launcher"
tagline: "Ad-free experience, custom themes, unlocked premium skins, and plugins." tagline: "Ad-free experience, custom themes, unlocked premium skins, and plugins."
image:
src: /logo.png
alt: BetterCurseForge
actions: actions:
- theme: brand - theme: brand
text: Get Started text: Get Started
link: /guide/installation link: /guide/installation
- theme: alt - theme: alt
text: View on GitHub text: View on GitHub
link: https://github.com/tomasalias/BetterCurseForge link: https://github.com/BetterCurseForge/BetterCurseForge
features: features:
- icon: 🚫 - icon: 🚫
@ -24,8 +21,8 @@ features:
title: Client Themes & Free Skins 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. details: Integrated directly into Settings -> Themes with live hover previews, theme folder shortcuts, and all native CurseForge Premium themes unlocked for free.
- icon: 🔄 - icon: 🔄
title: Auto-Repatch & Smooth Updates title: Auto-Repatch
details: Survives launcher updates effortlessly. The active interceptor suppresses unpatched windows, and a sleek splash GUI displays live progress while repatching in seconds. details: Survives launcher updates effortlessly. The automated background task detects CurseForge updates and silently re-patches the client.
- icon: 🖥️ - icon: 🖥️
title: Modern GUI & CLI 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. details: Includes an intuitive dark-themed desktop application with one-click actions, as well as a full command-line interface for power users.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,3 +1,20 @@
<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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<image href="logo.png" xlink:href="logo.png" x="0" y="0" width="512" height="512" /> <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> </svg>

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -22,12 +22,10 @@ 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" PATCH_MUTEX_NAME = "BetterCurseForgePatchMutex"
SPLASH_MUTEX_NAME = "BetterCurseForgeSplashMutex"
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"
PERMANENT_ASSETS = PERMANENT_DIR / "assets"
class GlobalNamedMutex: class GlobalNamedMutex:
def __init__(self, name: str, timeout_ms: int = 5000): def __init__(self, name: str, timeout_ms: int = 5000):
@ -113,79 +111,13 @@ def get_curseforge_dir() -> Path | None:
return p return p
return None 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(): def kill_curseforge():
kill_process_by_name("curseforge.exe")
try: try:
subprocess.run( subprocess.run(
["taskkill", "/f", "/im", "CurseForge.exe"], ["taskkill", "/f", "/im", "CurseForge.exe"],
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
) )
time.sleep(0.3) time.sleep(0.5)
except Exception: except Exception:
pass pass
@ -315,20 +247,6 @@ def find_vendor_dir() -> Path | None:
return c return c
return None 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]: 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 Executes asar pack/extract offline using bundled @electron/asar with either
@ -808,8 +726,6 @@ def unpatch_curseforge(log_callback=print) -> bool:
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# AUTO-REPATCH WATCHER & PERSISTENCE ENGINE # AUTO-REPATCH WATCHER & PERSISTENCE ENGINE
def is_curseforge_running() -> bool: def is_curseforge_running() -> bool:
if os.name == "nt":
return len(get_process_pids("curseforge.exe")) > 0
try: try:
out = subprocess.check_output( out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"], ["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
@ -820,96 +736,24 @@ def is_curseforge_running() -> bool:
return False return False
def is_updater_running() -> bool: def is_updater_running() -> bool:
""" try:
Checks if a CurseForge or Overwolf updater/installer process is currently running. out = subprocess.check_output(
Strictly scoped to avoid false positives from unrelated apps (Discord, Logitech, etc.). ["tasklist", "/FO", "CSV", "/NH"],
""" text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
if os.name == "nt": )
try: for line in out.splitlines():
import ctypes line = line.strip().lower()
from ctypes import wintypes if not line:
TH32CS_SNAPPROCESS = 0x00000002 continue
class PROCESSENTRY32(ctypes.Structure): parts = [p.strip('"') for p in line.split('","')]
_fields_ = [ if parts:
('dwSize', wintypes.DWORD), pname = parts[0]
('cntUsage', wintypes.DWORD), if pname in ("setup.exe", "installer.exe") or ("curseforge" in pname and "updater" in pname):
('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 return True
except Exception: except Exception:
pass pass
return False 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: def ensure_permanent_installation() -> Path:
""" """
Ensures BetterCurseForge executable, bettercf runtime, and vendor tools are permanently Ensures BetterCurseForge executable, bettercf runtime, and vendor tools are permanently
@ -931,13 +775,6 @@ def ensure_permanent_installation() -> Path:
shutil.rmtree(PERMANENT_VENDOR, ignore_errors=True) shutil.rmtree(PERMANENT_VENDOR, ignore_errors=True)
shutil.copytree(src_vendor, PERMANENT_VENDOR) 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): if getattr(sys, "frozen", False):
curr_exe = Path(sys.executable).resolve() curr_exe = Path(sys.executable).resolve()
if curr_exe != PERMANENT_EXE.resolve(): if curr_exe != PERMANENT_EXE.resolve():
@ -1050,12 +887,10 @@ def stop_background_watcher():
except Exception: except Exception:
pass pass
def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, intercept_unpatched: bool = True) -> bool: def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) -> bool:
""" """
Waits for the CurseForge updater/installer to finish writing files. Waits for the CurseForge updater/installer to finish writing files.
Ensures resources/app.asar is present, fully written, and readable. 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 import time
start = time.time() start = time.time()
@ -1064,15 +899,7 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, int
stable_checks = 0 stable_checks = 0
while time.time() - start < timeout: while time.time() - start < timeout:
# Check in 150ms slices (total ~1.5s per outer cycle) to actively silence unpatched CurseForge time.sleep(1.5)
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(): if is_updater_running():
stable_checks = 0 stable_checks = 0
@ -1109,12 +936,9 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, int
def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) -> bool: 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. Invoked when CurseForge is quitting to apply an update.
Spawns the dedicated Splash Updater GUI for a smooth visual transition, Waits for the old CurseForge instance to exit, waits for the installer to finish,
or falls back to headless repatch if UI cannot launch. and immediately repatches before/as CurseForge restarts.
""" """
if launch_splash_updater(pid_to_wait):
return True
import time import time
if log_fn is None: if log_fn is None:
log_dir = get_user_data_dir() log_dir = get_user_data_dir()
@ -1137,19 +961,27 @@ 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...") log_fn(f"Waiting for PID {pid_to_wait} to terminate...")
for _ in range(30): for _ in range(30):
try: try:
pids = get_process_pids("curseforge.exe") out = subprocess.check_output(
if pid_to_wait not in pids: ["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:
break break
except Exception: except Exception:
break break
time.sleep(0.5) time.sleep(0.5)
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn, intercept_unpatched=True) settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn)
if not settled: if not settled:
log_fn("Timed out waiting for installer to settle.") log_fn("Timed out waiting for installer to settle.")
return False return False
kill_curseforge() 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 success = False
for attempt in range(1, 4): for attempt in range(1, 4):
success = patch_curseforge(log_callback=log_fn) success = patch_curseforge(log_callback=log_fn)
@ -1204,46 +1036,35 @@ def run_watcher_loop():
wlog("CurseForge directory not found. Watcher exiting.") wlog("CurseForge directory not found. Watcher exiting.")
return return
last_splash_launch = 0.0
def check_and_repatch(trigger_reason: str): 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 not is_curseforge_patched(cf_dir):
if now - last_splash_launch < 15.0: wlog(f"CurseForge unpatched state detected ({trigger_reason}). Waiting for files to settle...")
return settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=wlog)
last_splash_launch = now if settled:
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_running = is_curseforge_running()
was_intercepted = [False] if was_running:
def on_fb_log(msg: str): wlog("CurseForge is currently running unpatched. Closing it to apply BetterCurseForge patch...")
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() kill_curseforge()
success = False time.sleep(1.5)
for attempt in range(1, 4):
success = patch_curseforge(log_callback=wlog) wlog("Applying BetterCurseForge patch...")
if success: success = False
break for attempt in range(1, 4):
time.sleep(2) success = patch_curseforge(log_callback=wlog)
if success: if success:
wlog("★ CurseForge successfully re-patched after update!") break
if was_running or was_intercepted[0]: wlog(f"Patch attempt {attempt} failed, retrying in 2 seconds...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW) time.sleep(2)
else:
wlog("Failed to re-patch CurseForge after update.") if success:
wlog("★ CurseForge successfully re-patched after update!")
if was_running:
wlog("Relaunching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
else: else:
wlog("Timed out waiting for installer files to settle.") wlog("Failed to re-patch CurseForge after update.")
else:
wlog("Timed out waiting for installer files to settle.")
check_and_repatch("Startup Check") check_and_repatch("Startup Check")

View file

@ -9,7 +9,7 @@ and providing zero-timer automatic re-patching after updates.
import os import os
import sys import sys
if os.name == "nt": if os.name == "nt":
if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"): if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher"):
import ctypes import ctypes
ctypes.windll.kernel32.AttachConsole(-1) ctypes.windll.kernel32.AttachConsole(-1)
if sys.stdout is not None: if sys.stdout is not None:
@ -25,12 +25,6 @@ from tkinter import ttk, messagebox, scrolledtext
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
try:
from PIL import Image, ImageTk
HAS_PIL = True
except ImportError:
HAS_PIL = False
# Import patch core logic # Import patch core logic
try: try:
from . import patch_core from . import patch_core
@ -39,20 +33,6 @@ except ImportError:
VERSION = "1.0.0" 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) # COLOR PALETTE (Modern Discord / Vencord Dark Theme)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -91,58 +71,11 @@ class BetterCFApp:
self.install_dir = None self.install_dir = None
self.is_working = False self.is_working = False
self._icon_photo = None
self._header_icon_photo = None
self._configure_styles() self._configure_styles()
self._setup_window_icon()
self._build_ui() self._build_ui()
self._refresh_status() 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): def _configure_styles(self):
style = ttk.Style() style = ttk.Style()
style.theme_use("clam") style.theme_use("clam")
@ -164,19 +97,8 @@ class BetterCFApp:
title_frame = tk.Frame(header, bg=COLORS["bg_card"]) title_frame = tk.Frame(header, bg=COLORS["bg_card"])
title_frame.pack(side="left") 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_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"] bg=COLORS["bg_card"], fg=COLORS["purple_glow"]
) )
title_lbl.pack(side="left") title_lbl.pack(side="left")
@ -469,262 +391,6 @@ class BetterCFApp:
self.log(f"BetterCurseForge Patcher v{VERSION} ready.") self.log(f"BetterCurseForge Patcher v{VERSION} ready.")
self.root.mainloop() 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 # MAIN ENTRY POINT
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -733,28 +399,24 @@ if __name__ == "__main__":
try: try:
if len(sys.argv) > 1: if len(sys.argv) > 1:
arg = sys.argv[1].lower() arg = sys.argv[1].lower()
if arg in ("--watch", "watch"): if arg == "--watch":
patch_core.run_watcher_loop() patch_core.run_watcher_loop()
sys.exit(0) sys.exit(0)
elif arg in ("--repatch-splash", "--splash", "splash", "--wait-for-update", "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
cf = patch_core.get_curseforge_dir() patch_core.wait_for_update_and_repatch(pid)
if not pid and cf and patch_core.is_curseforge_patched(cf) and not patch_core.is_updater_running():
sys.exit(0)
splash = SplashUpdaterWindow(pid)
splash.run()
sys.exit(0) sys.exit(0)
elif arg in ("--ensure-watcher", "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 in ("--patch", "patch"): elif arg == "patch":
patch_core.patch_curseforge() patch_core.patch_curseforge()
sys.exit(0) sys.exit(0)
elif arg in ("--unpatch", "unpatch"): elif arg == "unpatch":
patch_core.unpatch_curseforge() patch_core.unpatch_curseforge()
sys.exit(0) sys.exit(0)
elif arg in ("--status", "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)
@ -766,7 +428,7 @@ if __name__ == "__main__":
except SystemExit: except SystemExit:
pass pass
except Exception as e: except Exception as e:
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"): if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"):
try: try:
log_dir = patch_core.get_user_data_dir() log_dir = patch_core.get_user_data_dir()
with open(log_dir / "watcher.log", "a", encoding="utf-8") as f: with open(log_dir / "watcher.log", "a", encoding="utf-8") as f: