Compare commits
No commits in common. "c66cd9484a0683e4b0ee2adb32375040a2296fca" and "46dc6dc849e1a0094c757777e25196f7e2c596ee" have entirely different histories.
c66cd9484a
...
46dc6dc849
14 changed files with 201 additions and 793 deletions
65
.github/workflows/build.yml
vendored
65
.github/workflows/build.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -36,9 +36,3 @@ desktop.ini
|
|||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Local utility tools
|
||||
tools/
|
||||
update_icon.bat
|
||||
update_icon.py
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ a = Analysis(
|
|||
['patcher\\patcher_gui.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.'), ('assets', 'assets')],
|
||||
hiddenimports=['PIL', 'PIL.Image', 'PIL.ImageTk'],
|
||||
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
|
|
@ -35,5 +35,4 @@ exe = EXE(
|
|||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon='assets\\icon.ico',
|
||||
)
|
||||
|
|
|
|||
213
README.md
213
README.md
|
|
@ -1,73 +1,114 @@
|
|||
# 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)
|
||||
[](https://github.com)
|
||||
[](#features)
|
||||
[](#-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.
|
||||
- **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).
|
||||
</div>
|
||||
|
||||
## Install
|
||||
---
|
||||
|
||||
1. Download `BetterCurseForge.exe` from [Releases](https://github.com/tomasalias/BetterCurseForge/releases).
|
||||
2. Run it and click **Patch CurseForge**.
|
||||
3. Start CurseForge as usual.
|
||||
## ✨ Features
|
||||
|
||||
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
|
||||
# show patch and watcher status
|
||||
# Check current patch and watcher status
|
||||
.\BetterCurseForge.exe status
|
||||
|
||||
# apply the patch
|
||||
# Apply BetterCurseForge patch
|
||||
.\BetterCurseForge.exe patch
|
||||
|
||||
# go back to stock CurseForge
|
||||
# Restore original vanilla CurseForge
|
||||
.\BetterCurseForge.exe unpatch
|
||||
|
||||
# run the background watcher in the foreground
|
||||
# Run background watcher daemon directly
|
||||
.\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.
|
||||
|
||||
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
|
||||
```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 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
|
||||
{
|
||||
|
|
@ -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
|
||||
:root {
|
||||
--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
|
||||
{
|
||||
"name": "My Plugin",
|
||||
"description": "Custom tweak for CurseForge",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"main": "index.js",
|
||||
"styles": "styles.css"
|
||||
}
|
||||
```
|
||||
- Windows 10 / 11
|
||||
- Python 3.10+
|
||||
- Node.js 20+ (for docs)
|
||||
|
||||
`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.
|
||||
### Build Instructions
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/tomasalias/BetterCurseForge.git
|
||||
# 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
|
||||
```
|
||||
|
||||
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:
|
||||
### Running VitePress Documentation
|
||||
|
||||
```powershell
|
||||
cd docs
|
||||
|
|
@ -172,8 +160,9 @@ npm install
|
|||
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.
|
||||
|
|
|
|||
BIN
assets/icon.ico
BIN
assets/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
|||
cleanUrls: true,
|
||||
|
||||
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: 'og:type', content: 'website' }],
|
||||
['meta', { name: 'og:title', content: 'BetterCurseForge' }],
|
||||
|
|
@ -18,12 +18,11 @@ 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/tomasalias/BetterCurseForge' },
|
||||
{ text: 'GitHub', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
|
|
@ -58,7 +57,7 @@ export default defineConfig({
|
|||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/tomasalias/BetterCurseForge' },
|
||||
{ icon: 'github', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
|
||||
],
|
||||
|
||||
footer: {
|
||||
|
|
|
|||
|
|
@ -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/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).
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ Place it in any convenient folder (e.g., Downloads, Tools, or desktop).
|
|||
|
||||
Double-click `BetterCurseForge.exe` to launch the application.
|
||||
|
||||
{width=64 style="display:block;margin:1rem auto;"}
|
||||
{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 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
|
||||
- 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:
|
||||
.\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 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,16 +5,13 @@ 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/tomasalias/BetterCurseForge
|
||||
link: https://github.com/BetterCurseForge/BetterCurseForge
|
||||
|
||||
features:
|
||||
- icon: 🚫
|
||||
|
|
@ -24,8 +21,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 & 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.
|
||||
title: Auto-Repatch
|
||||
details: Survives launcher updates effortlessly. The automated background task detects CurseForge updates and silently re-patches the client.
|
||||
- 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.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -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">
|
||||
<image href="logo.png" xlink:href="logo.png" x="0" y="0" width="512" height="512" />
|
||||
<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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 225 B After Width: | Height: | Size: 1 KiB |
|
|
@ -22,12 +22,10 @@ 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):
|
||||
|
|
@ -113,79 +111,13 @@ 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=5, creationflags=CREATE_NO_WINDOW
|
||||
capture_output=True, timeout=10, creationflags=CREATE_NO_WINDOW
|
||||
)
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -315,20 +247,6 @@ 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
|
||||
|
|
@ -808,8 +726,6 @@ 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"],
|
||||
|
|
@ -820,94 +736,22 @@ 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:
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
|
|
@ -931,13 +775,6 @@ 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():
|
||||
|
|
@ -1050,12 +887,10 @@ def stop_background_watcher():
|
|||
except Exception:
|
||||
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.
|
||||
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()
|
||||
|
|
@ -1064,15 +899,7 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, int
|
|||
stable_checks = 0
|
||||
|
||||
while time.time() - start < timeout:
|
||||
# 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)
|
||||
time.sleep(1.5)
|
||||
|
||||
if is_updater_running():
|
||||
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:
|
||||
"""
|
||||
Invoked when CurseForge is quitting to apply an update.
|
||||
Spawns the dedicated Splash Updater GUI for a smooth visual transition,
|
||||
or falls back to headless repatch if UI cannot launch.
|
||||
Waits for the old CurseForge instance to exit, waits for the installer to finish,
|
||||
and immediately repatches before/as CurseForge restarts.
|
||||
"""
|
||||
if launch_splash_updater(pid_to_wait):
|
||||
return True
|
||||
|
||||
import time
|
||||
if log_fn is None:
|
||||
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...")
|
||||
for _ in range(30):
|
||||
try:
|
||||
pids = get_process_pids("curseforge.exe")
|
||||
if pid_to_wait not in pids:
|
||||
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:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
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:
|
||||
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)
|
||||
|
|
@ -1204,41 +1036,30 @@ def run_watcher_loop():
|
|||
wlog("CurseForge directory not found. Watcher exiting.")
|
||||
return
|
||||
|
||||
last_splash_launch = 0.0
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
wlog("Applying BetterCurseForge patch...")
|
||||
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 or was_intercepted[0]:
|
||||
if was_running:
|
||||
wlog("Relaunching modded CurseForge...")
|
||||
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
||||
else:
|
||||
wlog("Failed to re-patch CurseForge after update.")
|
||||
|
|
|
|||
|
|
@ -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", "--repatch-splash", "--splash"):
|
||||
if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher"):
|
||||
import ctypes
|
||||
ctypes.windll.kernel32.AttachConsole(-1)
|
||||
if sys.stdout is not None:
|
||||
|
|
@ -25,12 +25,6 @@ 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
|
||||
|
|
@ -39,20 +33,6 @@ 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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -91,58 +71,11 @@ 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")
|
||||
|
|
@ -164,19 +97,8 @@ 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")
|
||||
|
|
@ -469,262 +391,6 @@ 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
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -733,28 +399,24 @@ if __name__ == "__main__":
|
|||
try:
|
||||
if len(sys.argv) > 1:
|
||||
arg = sys.argv[1].lower()
|
||||
if arg in ("--watch", "watch"):
|
||||
if arg == "--watch":
|
||||
patch_core.run_watcher_loop()
|
||||
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
|
||||
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():
|
||||
patch_core.wait_for_update_and_repatch(pid)
|
||||
sys.exit(0)
|
||||
splash = SplashUpdaterWindow(pid)
|
||||
splash.run()
|
||||
sys.exit(0)
|
||||
elif arg in ("--ensure-watcher", "ensure-watcher"):
|
||||
elif arg == "--ensure-watcher":
|
||||
if not patch_core.is_watcher_running():
|
||||
patch_core.start_background_watcher()
|
||||
sys.exit(0)
|
||||
elif arg in ("--patch", "patch"):
|
||||
elif arg == "patch":
|
||||
patch_core.patch_curseforge()
|
||||
sys.exit(0)
|
||||
elif arg in ("--unpatch", "unpatch"):
|
||||
elif arg == "unpatch":
|
||||
patch_core.unpatch_curseforge()
|
||||
sys.exit(0)
|
||||
elif arg in ("--status", "status"):
|
||||
elif arg == "status":
|
||||
cf = patch_core.get_curseforge_dir()
|
||||
print("CurseForge:", cf)
|
||||
print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False)
|
||||
|
|
@ -766,7 +428,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", "--repatch-splash", "--splash"):
|
||||
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"):
|
||||
try:
|
||||
log_dir = patch_core.get_user_data_dir()
|
||||
with open(log_dir / "watcher.log", "a", encoding="utf-8") as f:
|
||||
|
|
|
|||
Loading…
Reference in a new issue