Compare commits
10 commits
46dc6dc849
...
c66cd9484a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66cd9484a | ||
|
|
0d7e20af42 | ||
|
|
1581dcd48b | ||
|
|
d1a415eb86 | ||
|
|
ca014969e1 | ||
|
|
5fc77ce66e | ||
|
|
a4e88c980d | ||
|
|
a8f11dcd53 | ||
|
|
872ead4406 | ||
|
|
96ad93b180 |
14 changed files with 800 additions and 208 deletions
65
.github/workflows/build.yml
vendored
Normal file
65
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: Build Windows Executable
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build BetterCurseForge.exe
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pyinstaller pillow pywin32
|
||||||
|
|
||||||
|
- name: Build executable with PyInstaller
|
||||||
|
run: |
|
||||||
|
python -m PyInstaller BetterCurseForge.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
run: |
|
||||||
|
if (-not (Test-Path "dist/BetterCurseForge.exe")) {
|
||||||
|
Write-Error "dist/BetterCurseForge.exe was not created!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Get-Item "dist/BetterCurseForge.exe" | Select-Object Name, Length, LastWriteTime
|
||||||
|
|
||||||
|
- name: Upload executable artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: BetterCurseForge-Windows
|
||||||
|
path: dist/BetterCurseForge.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Attach executable to GitHub Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/BetterCurseForge.exe
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -36,3 +36,9 @@ desktop.ini
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Local utility tools
|
||||||
|
tools/
|
||||||
|
update_icon.bat
|
||||||
|
update_icon.py
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', '.')],
|
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.'), ('assets', 'assets')],
|
||||||
hiddenimports=[],
|
hiddenimports=['PIL', 'PIL.Image', 'PIL.ImageTk'],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
|
|
@ -35,4 +35,5 @@ exe = EXE(
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
|
icon='assets\\icon.ico',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
227
README.md
227
README.md
|
|
@ -1,114 +1,73 @@
|
||||||
# BetterCurseForge
|
# BetterCurseForge
|
||||||
|
|
||||||
<div align="center">
|
<img src="assets/icon_512.png" alt="BetterCurseForge logo" width="96" height="96" />
|
||||||
|
|
||||||
<img src="docs/public/logo.svg" alt="BetterCurseForge Logo" width="128" height="128" />
|
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.
|
||||||
|
|
||||||
### The modern, lightweight client modification for CurseForge Launcher.
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com)
|
|
||||||
[](#features)
|
|
||||||
[](#-how-auto-repatch-works)
|
|
||||||
|
|
||||||
**Zero Ads • Custom Themes • Unlocked Skins • Extensible Plugins • Autonomous Auto-Repatch**
|
## What it does
|
||||||
|
|
||||||
</div>
|
- **Ad blocking.** Requests to the Overwolf, Adrta and DoubleClick ad servers are blocked at the Electron session level, and some CSS collapses whatever empty ad frames and banners are left. Ad elements are hidden, not removed from the DOM, because removing them breaks React and causes `insertBefore` errors.
|
||||||
|
- **Themes.** Themes show up in the launcher's own Settings > Themes page. CSS is injected live, so you don't need to restart to see changes. One theme is included, Purple Night.
|
||||||
|
- **Subscriber skins.** The official skins that normally need a CurseForge subscription (PixelForge, LifeForge, FantasyForge, Halloween and the rest) are unlocked.
|
||||||
|
- **Plugins.** Plugins are small JS modules with `onEnable` and `onDisable` hooks, scoped styling, and IPC to the main process. There's a sample one called Compact Mode.
|
||||||
|
- **Updates.** When CurseForge updates itself, the patch gets re-applied automatically. More on that [below](#how-the-repatch-works).
|
||||||
|
|
||||||
---
|
## Install
|
||||||
|
|
||||||
## ✨ Features
|
1. Download `BetterCurseForge.exe` from [Releases](https://github.com/tomasalias/BetterCurseForge/releases).
|
||||||
|
2. Run it and click **Patch CurseForge**.
|
||||||
|
3. Start CurseForge as usual.
|
||||||
|
|
||||||
- 🚫 **Complete Zero-Ad Experience**: Multi-layer ad elimination without breaking React Fiber:
|
It also works from PowerShell or Command Prompt:
|
||||||
- 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
|
||||||
# Check current patch and watcher status
|
# show patch and watcher status
|
||||||
.\BetterCurseForge.exe status
|
.\BetterCurseForge.exe status
|
||||||
|
|
||||||
# Apply BetterCurseForge patch
|
# apply the patch
|
||||||
.\BetterCurseForge.exe patch
|
.\BetterCurseForge.exe patch
|
||||||
|
|
||||||
# Restore original vanilla CurseForge
|
# go back to stock CurseForge
|
||||||
.\BetterCurseForge.exe unpatch
|
.\BetterCurseForge.exe unpatch
|
||||||
|
|
||||||
# Run background watcher daemon directly
|
# run the background watcher in the foreground
|
||||||
.\BetterCurseForge.exe watch
|
.\BetterCurseForge.exe watch
|
||||||
|
|
||||||
|
# open the splash updater by hand
|
||||||
|
.\BetterCurseForge.exe --repatch-splash
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## How the repatch works
|
||||||
|
|
||||||
## 🔄 How Auto-Repatch Works
|
When CurseForge downloads an update, the Overwolf installer overwrites `resources/app.asar` with a stock copy and relaunches `CurseForge.exe`. That undoes the patch, and the unpatched launcher would normally appear on screen. BetterCurseForge deals with it like this:
|
||||||
|
|
||||||
Whenever CurseForge downloads an update, the Overwolf installer replaces `resources/app.asar` with a vanilla archive and restarts `CurseForge.exe`. BetterCurseForge guarantees you never lose your patch:
|
A small watcher process sits in the background and watches CurseForge's install directory. When it sees an update or an unpatched `app.asar`, it polls for a new `CurseForge.exe` every 150 ms and kills the one the installer starts, so the vanilla launcher never gets drawn. Then it opens a splash window with a progress bar and runs the repatch.
|
||||||
|
|
||||||
```mermaid
|
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.
|
||||||
flowchart TD
|
|
||||||
A[CurseForge Launcher Update] -->|Installer downloads update| B[CurseForge Quitting / Replacing Files]
|
Two named Windows mutexes, `BetterCurseForgePatchMutex` and `BetterCurseForgeSplashMutex`, stop two patch runs or two splash windows from happening at the same time.
|
||||||
B -->|Watcher Detects Update| C[Auto-Repatch Triggered]
|
|
||||||
C -->|Wait for Installer Files to Settle| D[Settle Verified]
|
## Themes and plugins
|
||||||
D -->|Offline Extraction & Patch <3s| E[Dist & BetterCF Injected]
|
|
||||||
E -->|Recalculate ASAR SHA-256| F[Synchronize OWEASARSIG in CurseForge.exe]
|
Everything user-facing lives in `%APPDATA%\CurseForge\BetterCurseForge\`:
|
||||||
F -->|Autonomous Relaunch| G[Modded CurseForge Launcher Ready!]
|
|
||||||
|
```
|
||||||
|
themes/
|
||||||
|
purple-night.css
|
||||||
|
purple-night.json
|
||||||
|
plugins/
|
||||||
|
compact-mode/
|
||||||
|
manifest.json
|
||||||
|
index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Permanent Daemon Installation**: On first patch, BetterCurseForge installs to `%LOCALAPPDATA%\BetterCurseForge` with an autostart registry entry in `HKCU\...\Run` and a task scheduler fallback.
|
The GUI has **Open Themes Folder** and **Open Plugins Folder** buttons if you don't want to dig for it.
|
||||||
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
|
||||||
|
|
||||||
## 🎨 Themes & Plugins
|
A theme is a CSS file plus a JSON manifest with the same name. For example, `themes/my-theme.json`:
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|
@ -120,6 +79,8 @@ Create a CSS file and manifest in `%APPDATA%\CurseForge\BetterCurseForge\themes\
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
and `themes/my-theme.css`:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root {
|
:root {
|
||||||
--surface-brand-brand: #10b981 !important;
|
--surface-brand-brand: #10b981 !important;
|
||||||
|
|
@ -127,32 +88,83 @@ Create a CSS file and manifest in `%APPDATA%\CurseForge\BetterCurseForge\themes\
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Writing a plugin
|
||||||
|
|
||||||
## 🛠️ Building from Source
|
A plugin lives in its own subfolder in `plugins/<plugin-name>/` with a `manifest.json`, an `index.js`, and an optional companion `styles.css`.
|
||||||
|
|
||||||
### Prerequisites
|
`plugins/my-plugin/manifest.json`:
|
||||||
|
|
||||||
- Windows 10 / 11
|
```json
|
||||||
- Python 3.10+
|
{
|
||||||
- Node.js 20+ (for docs)
|
"name": "My Plugin",
|
||||||
|
"description": "Custom tweak for CurseForge",
|
||||||
### Build Instructions
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
```powershell
|
"main": "index.js",
|
||||||
# Clone the repository
|
"styles": "styles.css"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running VitePress Documentation
|
`plugins/my-plugin/index.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Plugins run inside the Electron renderer with access to BetterCurseForge
|
||||||
|
const { injectCSS, patcher, settings, on, off } = BetterCurseForge;
|
||||||
|
|
||||||
|
console.log("My plugin loaded");
|
||||||
|
|
||||||
|
// Cleanup when toggled off
|
||||||
|
plugin.onDisable = () => {
|
||||||
|
console.log("My plugin disabled");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### What plugins can do
|
||||||
|
|
||||||
|
- **UI & DOM tweaks.** Inject custom buttons, badges, links, or panels into CurseForge using standard DOM APIs. If a `styles.css` is present in the plugin folder, it is automatically injected when enabled and removed when disabled.
|
||||||
|
- **Method patching.** Intercept, modify, or override internal JavaScript functions with `BetterCurseForge.patcher.before()`, `after()`, or `instead()`.
|
||||||
|
- **Persistent settings.** Store and read user preferences across restarts using `BetterCurseForge.settings.get(key)` and `set(key, value)`.
|
||||||
|
- **Event listening.** Hook into application lifecycle events using `BetterCurseForge.on(event, callback)`.
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
You need Windows 10 or 11 and Python 3.10+. Node.js 20+ is only needed if you want to run the docs site.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/tomasalias/BetterCurseForge.git
|
||||||
|
cd BetterCurseForge
|
||||||
|
|
||||||
|
python -m pip install pyinstaller
|
||||||
|
python -m PyInstaller BetterCurseForge.spec --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable ends up in `dist/BetterCurseForge.exe`.
|
||||||
|
|
||||||
|
### Syncing changes from GitHub
|
||||||
|
|
||||||
|
To pull down updates made on GitHub:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have local changes that conflict:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git stash
|
||||||
|
git pull origin master
|
||||||
|
git stash pop
|
||||||
|
```
|
||||||
|
|
||||||
|
To push your local changes to GitHub:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add .
|
||||||
|
git commit -m "Description of changes"
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
To run the VitePress docs locally:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd docs
|
cd docs
|
||||||
|
|
@ -160,9 +172,8 @@ npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## License
|
||||||
|
|
||||||
## 📄 License
|
[MIT](LICENSE).
|
||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
CurseForge and Overwolf are trademarks of their respective owners. This is an independent open-source project and isn't affiliated with or endorsed by either of them.
|
||||||
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
Normal file
BIN
assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/icon_512.png
Normal file
BIN
assets/icon_512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
|
|
||||||
head: [
|
head: [
|
||||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
|
['link', { rel: 'icon', type: 'image/png', href: (process.env.BASE_URL || '/BetterCurseForge/') + 'logo.png' }],
|
||||||
['meta', { name: 'theme-color', content: '#9333ea' }],
|
['meta', { name: '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,11 +18,12 @@ 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/BetterCurseForge/BetterCurseForge' },
|
{ text: 'GitHub', link: 'https://github.com/tomasalias/BetterCurseForge' },
|
||||||
],
|
],
|
||||||
|
|
||||||
sidebar: [
|
sidebar: [
|
||||||
|
|
@ -57,7 +58,7 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
|
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{ icon: 'github', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
|
{ icon: 'github', link: 'https://github.com/tomasalias/BetterCurseForge' },
|
||||||
],
|
],
|
||||||
|
|
||||||
footer: {
|
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 the latest executable from the official releases:
|
||||||
|
|
||||||
- [Download `BetterCurseForge.exe`](https://github.com/BetterCurseForge/BetterCurseForge/releases/latest)
|
- [Download `BetterCurseForge.exe`](https://github.com/tomasalias/BetterCurseForge/releases/latest)
|
||||||
|
|
||||||
Place it in any convenient folder (e.g., Downloads, Tools, or desktop).
|
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.
|
||||||
|
|
||||||
{width=64 style="display:block;margin:1rem auto;"}
|
{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 a new version, temporarily removing client modifications.
|
When the official CurseForge Launcher downloads an automatic update, it overwrites `app.asar` with an unpatched version and attempts to restart.
|
||||||
|
|
||||||
BetterCurseForge solves this with **Auto-Repatch**:
|
BetterCurseForge provides an autonomous, smooth transition with **zero screen flashing**:
|
||||||
|
|
||||||
### Managing Auto-Repatch in the GUI
|
### 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,15 +125,20 @@ BetterCurseForge solves this with **Auto-Repatch**:
|
||||||
# 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.
|
1. **Background Watcher**: Runs silently with 0% CPU consumption via Windows directory event notifications and heartbeat checks.
|
||||||
2. **Global Mutex Lock**: Uses kernel named mutexes to guarantee single-instance execution and avoid race conditions.
|
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. **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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,16 @@ 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/BetterCurseForge/BetterCurseForge
|
link: https://github.com/tomasalias/BetterCurseForge
|
||||||
|
|
||||||
features:
|
features:
|
||||||
- icon: 🚫
|
- icon: 🚫
|
||||||
|
|
@ -21,8 +24,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
|
title: Auto-Repatch & Smooth Updates
|
||||||
details: Survives launcher updates effortlessly. The automated background task detects CurseForge updates and silently re-patches the client.
|
details: Survives launcher updates effortlessly. The active interceptor suppresses unpatched windows, and a sleek splash GUI displays live progress while repatching in seconds.
|
||||||
- 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.
|
||||||
|
|
|
||||||
BIN
docs/public/logo.png
Normal file
BIN
docs/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -1,20 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
<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">
|
||||||
<defs>
|
<image href="logo.png" xlink:href="logo.png" x="0" y="0" width="512" height="512" />
|
||||||
<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: 1 KiB After Width: | Height: | Size: 225 B |
|
|
@ -22,10 +22,12 @@ 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):
|
||||||
|
|
@ -111,13 +113,79 @@ 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=10, creationflags=CREATE_NO_WINDOW
|
capture_output=True, timeout=5, creationflags=CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
time.sleep(0.5)
|
time.sleep(0.3)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -247,6 +315,20 @@ 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
|
||||||
|
|
@ -726,6 +808,8 @@ 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"],
|
||||||
|
|
@ -736,24 +820,96 @@ def is_curseforge_running() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_updater_running() -> bool:
|
def is_updater_running() -> bool:
|
||||||
try:
|
"""
|
||||||
out = subprocess.check_output(
|
Checks if a CurseForge or Overwolf updater/installer process is currently running.
|
||||||
["tasklist", "/FO", "CSV", "/NH"],
|
Strictly scoped to avoid false positives from unrelated apps (Discord, Logitech, etc.).
|
||||||
text=True, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
"""
|
||||||
)
|
if os.name == "nt":
|
||||||
for line in out.splitlines():
|
try:
|
||||||
line = line.strip().lower()
|
import ctypes
|
||||||
if not line:
|
from ctypes import wintypes
|
||||||
continue
|
TH32CS_SNAPPROCESS = 0x00000002
|
||||||
parts = [p.strip('"') for p in line.split('","')]
|
class PROCESSENTRY32(ctypes.Structure):
|
||||||
if parts:
|
_fields_ = [
|
||||||
pname = parts[0]
|
('dwSize', wintypes.DWORD),
|
||||||
if pname in ("setup.exe", "installer.exe") or ("curseforge" in pname and "updater" in pname):
|
('cntUsage', wintypes.DWORD),
|
||||||
|
('th32ProcessID', wintypes.DWORD),
|
||||||
|
('th32DefaultHeapID', ctypes.c_size_t),
|
||||||
|
('th32ModuleID', wintypes.DWORD),
|
||||||
|
('cntThreads', wintypes.DWORD),
|
||||||
|
('th32ParentProcessID', wintypes.DWORD),
|
||||||
|
('pcPriClassBase', ctypes.c_long),
|
||||||
|
('dwFlags', wintypes.DWORD),
|
||||||
|
('szExeFile', ctypes.c_char * 260)
|
||||||
|
]
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
h_snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||||
|
if h_snap != -1 and h_snap:
|
||||||
|
pe = PROCESSENTRY32()
|
||||||
|
pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
|
||||||
|
found = False
|
||||||
|
if kernel32.Process32First(h_snap, ctypes.byref(pe)):
|
||||||
|
while True:
|
||||||
|
name = pe.szExeFile.decode('mbcs', errors='ignore').lower()
|
||||||
|
# Strictly match CurseForge or Overwolf installer/updater processes
|
||||||
|
if ('curseforge' in name and any(k in name for k in ('update', 'setup', 'install'))) or \
|
||||||
|
('overwolf' in name and any(k in name for k in ('installer', 'updater', 'setup'))):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not kernel32.Process32Next(h_snap, ctypes.byref(pe)):
|
||||||
|
break
|
||||||
|
kernel32.CloseHandle(h_snap)
|
||||||
|
if found:
|
||||||
return True
|
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
|
||||||
|
|
@ -775,6 +931,13 @@ 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():
|
||||||
|
|
@ -887,10 +1050,12 @@ 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) -> bool:
|
def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None, intercept_unpatched: bool = True) -> bool:
|
||||||
"""
|
"""
|
||||||
Waits for the CurseForge updater/installer to finish writing files.
|
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()
|
||||||
|
|
@ -899,7 +1064,15 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) ->
|
||||||
stable_checks = 0
|
stable_checks = 0
|
||||||
|
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
time.sleep(1.5)
|
# Check in 150ms slices (total ~1.5s per outer cycle) to actively silence unpatched CurseForge
|
||||||
|
for _ in range(10):
|
||||||
|
if intercept_unpatched:
|
||||||
|
pids = get_process_pids("curseforge.exe")
|
||||||
|
if pids:
|
||||||
|
kill_process_by_name("curseforge.exe")
|
||||||
|
if log_fn:
|
||||||
|
log_fn("Silencing vanilla launcher in background...")
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
if is_updater_running():
|
if is_updater_running():
|
||||||
stable_checks = 0
|
stable_checks = 0
|
||||||
|
|
@ -936,9 +1109,12 @@ def wait_for_files_settled(cf_dir: Path, timeout: float = 90.0, log_fn=None) ->
|
||||||
def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) -> bool:
|
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.
|
||||||
Waits for the old CurseForge instance to exit, waits for the installer to finish,
|
Spawns the dedicated Splash Updater GUI for a smooth visual transition,
|
||||||
and immediately repatches before/as CurseForge restarts.
|
or falls back to headless repatch if UI cannot launch.
|
||||||
"""
|
"""
|
||||||
|
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()
|
||||||
|
|
@ -961,27 +1137,19 @@ def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) ->
|
||||||
log_fn(f"Waiting for PID {pid_to_wait} to terminate...")
|
log_fn(f"Waiting for PID {pid_to_wait} to terminate...")
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
pids = get_process_pids("curseforge.exe")
|
||||||
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"],
|
if pid_to_wait not in pids:
|
||||||
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)
|
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=log_fn, intercept_unpatched=True)
|
||||||
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
|
||||||
|
|
||||||
was_running = is_curseforge_running()
|
kill_curseforge()
|
||||||
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)
|
||||||
|
|
@ -1036,35 +1204,46 @@ 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):
|
||||||
wlog(f"CurseForge unpatched state detected ({trigger_reason}). Waiting for files to settle...")
|
if now - last_splash_launch < 15.0:
|
||||||
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=wlog)
|
return
|
||||||
if settled:
|
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_running = is_curseforge_running()
|
||||||
if was_running:
|
was_intercepted = [False]
|
||||||
wlog("CurseForge is currently running unpatched. Closing it to apply BetterCurseForge patch...")
|
def on_fb_log(msg: str):
|
||||||
|
if "silenc" in msg.lower():
|
||||||
|
was_intercepted[0] = True
|
||||||
|
wlog(msg)
|
||||||
|
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=on_fb_log, intercept_unpatched=True)
|
||||||
|
if settled:
|
||||||
kill_curseforge()
|
kill_curseforge()
|
||||||
time.sleep(1.5)
|
success = False
|
||||||
|
for attempt in range(1, 4):
|
||||||
wlog("Applying BetterCurseForge patch...")
|
success = patch_curseforge(log_callback=wlog)
|
||||||
success = False
|
if success:
|
||||||
for attempt in range(1, 4):
|
break
|
||||||
success = patch_curseforge(log_callback=wlog)
|
time.sleep(2)
|
||||||
if success:
|
if success:
|
||||||
break
|
wlog("★ CurseForge successfully re-patched after update!")
|
||||||
wlog(f"Patch attempt {attempt} failed, retrying in 2 seconds...")
|
if was_running or was_intercepted[0]:
|
||||||
time.sleep(2)
|
subprocess.Popen([str(cf_dir / "CurseForge.exe")], creationflags=CREATE_NO_WINDOW)
|
||||||
|
else:
|
||||||
if success:
|
wlog("Failed to re-patch CurseForge after update.")
|
||||||
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("Failed to re-patch CurseForge after update.")
|
wlog("Timed out waiting for installer files to settle.")
|
||||||
else:
|
|
||||||
wlog("Timed out waiting for installer files to settle.")
|
|
||||||
|
|
||||||
check_and_repatch("Startup Check")
|
check_and_repatch("Startup Check")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"):
|
||||||
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,6 +25,12 @@ 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
|
||||||
|
|
@ -33,6 +39,20 @@ 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)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -71,11 +91,58 @@ 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")
|
||||||
|
|
@ -97,8 +164,19 @@ 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")
|
||||||
|
|
@ -391,6 +469,262 @@ 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
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -399,24 +733,28 @@ 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 == "--watch":
|
if arg in ("--watch", "watch"):
|
||||||
patch_core.run_watcher_loop()
|
patch_core.run_watcher_loop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif arg == "--wait-for-update":
|
elif arg in ("--repatch-splash", "--splash", "splash", "--wait-for-update", "wait-for-update"):
|
||||||
pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
|
pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
|
||||||
patch_core.wait_for_update_and_repatch(pid)
|
cf = patch_core.get_curseforge_dir()
|
||||||
|
if not pid and cf and patch_core.is_curseforge_patched(cf) and not patch_core.is_updater_running():
|
||||||
|
sys.exit(0)
|
||||||
|
splash = SplashUpdaterWindow(pid)
|
||||||
|
splash.run()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif arg == "--ensure-watcher":
|
elif arg in ("--ensure-watcher", "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 == "patch":
|
elif arg in ("--patch", "patch"):
|
||||||
patch_core.patch_curseforge()
|
patch_core.patch_curseforge()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif arg == "unpatch":
|
elif arg in ("--unpatch", "unpatch"):
|
||||||
patch_core.unpatch_curseforge()
|
patch_core.unpatch_curseforge()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif arg == "status":
|
elif arg in ("--status", "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)
|
||||||
|
|
@ -428,7 +766,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"):
|
if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher", "--repatch-splash", "--splash"):
|
||||||
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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue