feat: initial commit for BetterCurseForge client modification suite

This commit is contained in:
tomasalias 2026-10-06 17:03:16 +02:00
commit c20abd8790
79 changed files with 13985 additions and 0 deletions

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# PyInstaller build outputs
build/
dist/
*.manifest
# Node / npm
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
!patcher/vendor/asar/node_modules/
# VitePress build outputs & cache
docs/.vitepress/dist/
docs/.vitepress/cache/
# User data, backups, logs & temp files
*.vanilla
*.bak
*.log
*.tmp
repatch.log
watcher.log
temp*
# OS and Editor files
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
*.swp
*.swo

38
BetterCurseForge.spec Normal file
View file

@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['patcher\\patcher_gui.py'],
pathex=[],
binaries=[],
datas=[('bettercf', 'bettercf'), ('patcher\\vendor', 'vendor'), ('patcher\\patch_core.py', '.')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='BetterCurseForge',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 BetterCurseForge Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

165
README.md Normal file
View file

@ -0,0 +1,165 @@
# BetterCurseForge
<div align="center">
<img src="docs/public/logo.svg" alt="BetterCurseForge Logo" width="128" height="128" />
### The modern, lightweight client modification for CurseForge Launcher.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-Windows-0078D6.svg?logo=windows)](https://github.com)
[![Zero Ads](https://img.shields.io/badge/Ads-Completely%20Blocked-success.svg)](#features)
[![Auto Repatch](https://img.shields.io/badge/Auto--Repatch-Autonomous-brightgreen.svg)](#-how-auto-repatch-works)
**Zero Ads • Custom Themes • Unlocked Skins • Extensible Plugins • Autonomous Auto-Repatch**
</div>
---
## ✨ Features
- 🚫 **Complete Zero-Ad Experience**: Multi-layer ad elimination:
- Electron `session.webRequest` network request blocker stops Overwolf/Adrta/DoubleClick ad calls before they start.
- React component interception replaces ad renderers with clean empty nodes.
- Fail-safe CSS rules neutralize ad frames, banners, and swap buttons.
- 🎨 **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
# Check current patch and watcher status
.\BetterCurseForge.exe status
# Apply BetterCurseForge patch
.\BetterCurseForge.exe patch
# Restore original vanilla CurseForge
.\BetterCurseForge.exe unpatch
```
---
## 🔄 How Auto-Repatch Works
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:
```mermaid
flowchart TD
A[CurseForge Launcher Update] -->|Installer downloads update| B[CurseForge Quitting / Replacing Files]
B -->|Hook & Watcher Detect Update| C[Auto-Repatch Triggered]
C -->|Wait for Installer 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!]
```
1. **Permanent Daemon Installation**: On first patch, BetterCurseForge installs to `%LOCALAPPDATA%\BetterCurseForge` with an autostart registry entry in `HKCU\...\Run`.
2. **Proactive In-App Update Interceptor**: BetterCurseForge monitors updater states on app quit (`before-quit`) to initiate patching before the new launcher process initializes.
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 terminates the unpatched process, reapplies the patch, and relaunches the modded client.
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 3–4 seconds without requiring an internet connection.
---
## 🎨 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
{
"name": "My Theme",
"description": "Custom color theme for CurseForge",
"author": "Your Name",
"version": "1.0.0",
"css": "my-theme.css"
}
```
```css
:root {
--surface-brand-brand: #10b981 !important;
--surface-brand-brand-hover: #059669 !important;
}
```
---
## 🛠️ Building from Source
### Prerequisites
- Windows 10 / 11
- Python 3.10+
- Node.js 20+ (for docs)
### Build Instructions
```powershell
# Clone the repository
git clone https://github.com/your-username/BetterCurseForge.git
cd BetterCurseForge
# Build standalone executable with PyInstaller
python -m pip install pyinstaller
python -m PyInstaller BetterCurseForge.spec --noconfirm
# Output executable will be in:
# dist/BetterCurseForge.exe
```
### Running VitePress Documentation
```powershell
cd docs
npm install
npm run dev
```
---
## 📄 License
This project is licensed under the [MIT License](LICENSE).
CurseForge and Overwolf are trademarks of their respective owners. BetterCurseForge is an independent open-source project and is not affiliated with or endorsed by Overwolf or CurseForge.

View file

@ -0,0 +1,310 @@
// BetterCurseForge — Main Process Hooks
// ======================================
// Runs in Electron's main process BEFORE CurseForge loads.
// Blocks ad network requests via session.webRequest.
// Manages themes, plugins, and settings IPC channels.
import { app, session, ipcMain, shell } from 'electron';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const USER_DATA = join(app.getPath('userData'), 'BetterCurseForge');
const THEMES_DIR = join(USER_DATA, 'themes');
const PLUGINS_DIR = join(USER_DATA, 'plugins');
const SETTINGS_FILE = join(USER_DATA, 'settings.json');
// Ensure user directories exist
[THEMES_DIR, PLUGINS_DIR].forEach(d => { try { mkdirSync(d, { recursive: true }); } catch {} });
// Seed built-in themes and plugins if user directories are empty
try {
const builtinThemes = join(__dirname, '..', 'themes');
if (existsSync(builtinThemes)) {
for (const f of readdirSync(builtinThemes)) {
try {
const src = join(builtinThemes, f);
const dst = join(THEMES_DIR, f);
if (!existsSync(dst)) {
writeFileSync(dst, readFileSync(src));
}
} catch {}
}
}
} catch {}
// ── Settings ──────────────────────────────────────────────────
let settings = {
adsBlocked: true,
enabledThemes: [],
enabledPlugins: [],
activeCustomTheme: null,
};
try {
if (existsSync(SETTINGS_FILE)) {
Object.assign(settings, JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8')));
}
} catch {}
function saveSettings() {
try { writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } catch {}
}
// ── Ad Blocking (Network Layer) ───────────────────────────────
const AD_URL_PATTERNS = [
'*://*.overwolf.com/ads/*',
'*://ads.overwolf.com/*',
'*://content.overwolf.com/ads/*',
'*://*.adrta.com/*',
'*://*.adnxs.com/*',
'*://*.doubleclick.net/*',
'*://*.googlesyndication.com/*',
'*://*.googleadservices.com/*',
'*://*.moatads.com/*',
'*://*.amazon-adsystem.com/*',
'*://*.adsrvr.org/*',
'*://*.adscorebr.com/*',
'*://*.serving-sys.com/*',
'*://*.adskeeper.co.uk/*',
];
app.whenReady().then(() => {
if (!settings.adsBlocked) return;
try {
const ses = session.defaultSession;
ses.webRequest.onBeforeRequest({ urls: AD_URL_PATTERNS }, (details, callback) => {
callback({ cancel: true });
});
console.log('[BetterCF] Network ad blocking enabled.');
} catch (err) {
console.error('[BetterCF] Failed to set up ad blocking:', err);
}
});
// ── Renderer Injection via app event ──────────────────────────
const rendererCode = (() => {
const rendererPath = join(__dirname, 'renderer.js');
if (existsSync(rendererPath)) {
return readFileSync(rendererPath, 'utf-8');
}
return null;
})();
app.on('browser-window-created', (_event, window) => {
if (!rendererCode) return;
window.webContents.on('did-finish-load', () => {
// Inject the IPC bridge first (since contextIsolation is false, this works)
const ipcBridge = `
if (!window.__BCF_IPC__) {
try {
const { ipcRenderer } = require('electron');
window.__BCF_IPC__ = {
invoke: (...args) => ipcRenderer.invoke(...args),
};
} catch (e) {}
}
window.__BCF_CORE_PATH__ = ${JSON.stringify(__dirname)};
`;
window.webContents.executeJavaScript(ipcBridge).then(() => {
return window.webContents.executeJavaScript(rendererCode);
}).then(() => {
console.log('[BetterCF] Renderer injected into window.');
}).catch(err => {
console.error('[BetterCF] Renderer injection failed:', err);
});
});
});
// ── IPC Channels ──────────────────────────────────────────────
ipcMain.handle('bcf:themes:list', () => {
try {
if (!existsSync(THEMES_DIR)) return [];
const entries = readdirSync(THEMES_DIR, { withFileTypes: true });
const themes = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.css')) {
const name = entry.name.replace('.css', '');
const css = readFileSync(join(THEMES_DIR, entry.name), 'utf-8');
const manifestPath = join(THEMES_DIR, `${name}.json`);
let manifest = {
name: name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
author: 'Custom',
description: '',
version: '1.0.0',
primaryColor: '#8b5cf6',
titleColor: '#a78bfa',
bodyColor: '#2e1065',
backgroundColor: '#161026',
};
if (existsSync(manifestPath)) {
try { Object.assign(manifest, JSON.parse(readFileSync(manifestPath, 'utf-8'))); } catch {}
}
themes.push({
...manifest,
fileName: entry.name,
css,
enabled: settings.enabledThemes.includes(entry.name) || settings.activeCustomTheme === entry.name,
});
} else if (entry.isDirectory()) {
const cssPath = join(THEMES_DIR, entry.name, 'theme.css');
const manifestPath = join(THEMES_DIR, entry.name, 'theme.json');
if (existsSync(cssPath)) {
const css = readFileSync(cssPath, 'utf-8');
let manifest = {
name: entry.name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
author: 'Custom',
description: '',
version: '1.0.0',
primaryColor: '#8b5cf6',
titleColor: '#a78bfa',
bodyColor: '#2e1065',
backgroundColor: '#161026',
};
if (existsSync(manifestPath)) {
try { Object.assign(manifest, JSON.parse(readFileSync(manifestPath, 'utf-8'))); } catch {}
}
themes.push({
...manifest,
fileName: entry.name,
css,
enabled: settings.enabledThemes.includes(entry.name) || settings.activeCustomTheme === entry.name,
});
}
}
}
return themes;
} catch { return []; }
});
ipcMain.handle('bcf:plugins:list', () => {
try {
if (!existsSync(PLUGINS_DIR)) return [];
const entries = readdirSync(PLUGINS_DIR, { withFileTypes: true });
const plugins = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(PLUGINS_DIR, entry.name, 'manifest.json');
if (!existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const indexPath = join(PLUGINS_DIR, entry.name, 'index.js');
const stylePath = join(PLUGINS_DIR, entry.name, 'styles.css');
plugins.push({
...manifest, dirName: entry.name,
hasScript: existsSync(indexPath), hasStyles: existsSync(stylePath),
enabled: settings.enabledPlugins.includes(entry.name),
script: existsSync(indexPath) ? readFileSync(indexPath, 'utf-8') : null,
styles: existsSync(stylePath) ? readFileSync(stylePath, 'utf-8') : null,
});
} catch {}
}
return plugins;
} catch { return []; }
});
ipcMain.handle('bcf:settings:get', () => ({ ...settings }));
ipcMain.handle('bcf:settings:set', (_, key, value) => {
settings[key] = value;
saveSettings();
return true;
});
ipcMain.handle('bcf:settings:toggle-theme', (_, fileName) => {
const idx = settings.enabledThemes.indexOf(fileName);
if (idx >= 0) settings.enabledThemes.splice(idx, 1);
else settings.enabledThemes.push(fileName);
saveSettings();
return settings.enabledThemes.includes(fileName);
});
ipcMain.handle('bcf:settings:toggle-plugin', (_, dirName) => {
const idx = settings.enabledPlugins.indexOf(dirName);
if (idx >= 0) settings.enabledPlugins.splice(idx, 1);
else settings.enabledPlugins.push(dirName);
saveSettings();
return settings.enabledPlugins.includes(dirName);
});
ipcMain.handle('bcf:open-themes-folder', async () => {
try {
await shell.openPath(THEMES_DIR);
return true;
} catch {
return false;
}
});
ipcMain.handle('bcf:open-plugins-folder', async () => {
try {
await shell.openPath(PLUGINS_DIR);
return true;
} catch {
return false;
}
});
ipcMain.handle('bcf:info', () => ({
version: '1.0.0',
bcfRoot: USER_DATA,
themesDir: THEMES_DIR,
pluginsDir: PLUGINS_DIR,
settingsFile: SETTINGS_FILE,
}));
console.log('[BetterCF] Main process hooks installed.');
// ── Auto-Repatch on Update Interceptor ────────────────────────
try {
const { spawn } = await import('node:child_process');
const patcherExe = join(process.env.LOCALAPPDATA || '', 'BetterCurseForge', 'BetterCurseForge.exe');
const onUpdateTriggered = () => {
try {
if (existsSync(patcherExe)) {
spawn(patcherExe, ['--wait-for-update', process.pid.toString()], {
detached: true,
stdio: 'ignore'
}).unref();
console.log('[BetterCF] Update pending. Spawned patcher --wait-for-update');
}
} catch (e) {
console.error('[BetterCF] Failed to spawn update patcher:', e);
}
};
// 1. Check if curseforge-updater has a pending update when quitting
app.on('before-quit', () => {
const pendingInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'pending', 'setup.exe');
const directInstaller = join(process.env.LOCALAPPDATA || '', 'curseforge-updater', 'installer.exe');
if (existsSync(pendingInstaller) || existsSync(directInstaller)) {
onUpdateTriggered();
}
});
// 2. Ensure watcher daemon is running
setTimeout(() => {
try {
if (existsSync(patcherExe)) {
spawn(patcherExe, ['--ensure-watcher'], {
detached: true,
stdio: 'ignore'
}).unref();
}
} catch {}
}, 4000);
} catch (err) {
console.error('[BetterCF] Auto-repatch hook initialization error:', err);
}

79
bettercf/core/preload.js Normal file
View file

@ -0,0 +1,79 @@
// BetterCurseForge — Preload Script
// ==================================
// Injected into every BrowserWindow's preload phase.
// Chain-loads the original CurseForge preload, then injects the renderer runtime.
// Since contextIsolation is false, we have direct access to the renderer's JS context.
const { ipcRenderer } = require('electron');
const path = require('path');
const fs = require('fs');
// ── Find and load original preload ────────────────────────────
const args = process.argv;
let originalPreloadPath = null;
for (const arg of args) {
if (arg.startsWith('--bcf-original-preload=')) {
originalPreloadPath = arg.split('=')[1];
break;
}
}
if (originalPreloadPath && fs.existsSync(originalPreloadPath)) {
try {
require(originalPreloadPath);
console.log('[BetterCF] Original preload loaded:', originalPreloadPath);
} catch (err) {
console.error('[BetterCF] Failed to load original preload:', err);
}
}
// ── Inject Renderer Runtime ───────────────────────────────────
const bcfInjected = args.some(a => a === '--bcf-injected=true');
if (bcfInjected) {
// Wait for DOM to be ready, then inject our renderer
const injectRenderer = () => {
const bcfCorePath = path.join(__dirname, 'bettercf', 'core');
const rendererPath = path.join(bcfCorePath, 'renderer.js');
if (!fs.existsSync(rendererPath)) {
console.warn('[BetterCF] renderer.js not found at', rendererPath);
return;
}
// Read renderer code and inject via inline script
// CSP allows 'unsafe-inline' so this works
const rendererCode = fs.readFileSync(rendererPath, 'utf-8');
// Create the BetterCurseForge API on the window object
// This needs to bridge to the main process via IPC
const apiSetup = `
window.__BCF_IPC__ = {
invoke: (...args) => require('electron').ipcRenderer.invoke(...args),
};
window.__BCF_PRELOAD_PATH__ = ${JSON.stringify(bcfCorePath)};
`;
// Inject API setup immediately
const apiScript = document.createElement('script');
apiScript.textContent = apiSetup;
(document.head || document.documentElement).appendChild(apiScript);
// Inject renderer after a short delay to ensure DOM is ready
const rendererScript = document.createElement('script');
rendererScript.type = 'module';
rendererScript.textContent = rendererCode;
(document.head || document.documentElement).appendChild(rendererScript);
console.log('[BetterCF] Renderer injected.');
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectRenderer);
} else {
injectRenderer();
}
}

933
bettercf/core/renderer.js Normal file
View file

@ -0,0 +1,933 @@
// BetterCurseForge — Renderer Runtime
// ====================================
// Runs in the renderer process (the CurseForge UI).
// Handles ad removal, client themes in Settings -> Themes, plugins, and public API.
(function () {
'use strict';
if (window.__BCF_RENDERER_LOADED__) {
console.log('[BetterCF] Renderer runtime already loaded.');
return;
}
window.__BCF_RENDERER_LOADED__ = true;
// Fallback IPC bridge if not set by preload
if (!window.__BCF_IPC__) {
try {
const { ipcRenderer } = require('electron');
window.__BCF_IPC__ = {
invoke: (...args) => ipcRenderer.invoke(...args),
};
} catch {}
}
const ipc = window.__BCF_IPC__;
// ═══════════════════════════════════════════════════════════════
// AD REMOVAL
// ═══════════════════════════════════════════════════════════════
const AdBlocker = {
_observer: null,
_style: null,
init() {
this._style = document.createElement('style');
this._style.id = 'bcf-adblock';
this._style.textContent = `
/* ═══ BetterCurseForge Ad Blocker ═══ */
.curseforge-ad,
.ad-inner,
.overwolf-ad,
.affiliate-banner,
.swap-ads-location,
.subscribe-link,
.subscribe-link-small-resolution,
.subscribe-link-large-resolution,
.promo-banner {
display: none !important;
width: 0 !important;
height: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
position: absolute !important;
visibility: hidden !important;
}
owadview {
display: none !important;
width: 0 !important;
height: 0 !important;
position: absolute !important;
visibility: hidden !important;
}
[class*="ad-container"],
[class*="ad-banner"],
[class*="high-impact-ad"],
[data-ad],
[data-ad-slot] {
display: none !important;
}
[style*="grid-template"][style*="ads"] {
grid-template-columns: 1fr !important;
grid-template-areas: none !important;
}
`;
(document.head || document.documentElement).appendChild(this._style);
this._observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
this._removeAds(node);
}
}
});
this._observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
setTimeout(() => this._sweepAds(), 1000);
setTimeout(() => this._sweepAds(), 3000);
setTimeout(() => this._sweepAds(), 6000);
},
_removeAds(node) {
if (node.tagName?.toLowerCase() === 'owadview') {
const parent = node.parentElement;
node.remove();
if (parent && parent.children.length === 0) {
parent.style.display = 'none';
}
return;
}
const classes = node.className?.toString() || '';
if (/\b(ad-container|ad-banner|ads-wrapper|AdView|high-impact|display-ad)\b/i.test(classes)) {
node.remove();
return;
}
const adChildren = node.querySelectorAll?.('owadview, [class*="ad-container"], [class*="AdView"], [class*="high-impact"]');
if (adChildren) {
for (const child of adChildren) {
child.remove();
}
}
},
_sweepAds() {
const adSelectors = [
'.curseforge-ad',
'.ad-inner',
'.overwolf-ad',
'.affiliate-banner',
'.subscribe-link',
'.subscribe-link-small-resolution',
'.subscribe-link-large-resolution',
'.promo-banner',
'owadview',
];
const els = document.querySelectorAll(adSelectors.join(', '));
for (const el of els) {
el.remove();
}
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, null);
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim();
if (text && /^Ads support authors/i.test(text)) {
let parent = walker.currentNode.parentElement;
while (parent && parent !== document.body) {
if (parent.children.length <= 2) {
parent.style.display = 'none';
break;
}
parent = parent.parentElement;
}
}
}
},
};
// ═══════════════════════════════════════════════════════════════
// THEME SYSTEM
// ═══════════════════════════════════════════════════════════════
const ThemeManager = {
_themes: [],
_activeThemeFileName: null,
_savedThemeFileName: null,
_styleElement: null,
_isPreviewing: false,
_isSuspendedForNative: false,
async init() {
this._injectBaseStyles();
// Read stored active theme preference
this._savedThemeFileName = localStorage.getItem('bcf_active_custom_theme') || null;
if (ipc) {
try {
const s = await ipc.invoke('bcf:settings:get');
if (s && s.activeCustomTheme !== undefined) {
this._savedThemeFileName = s.activeCustomTheme;
}
} catch {}
}
this._activeThemeFileName = this._savedThemeFileName;
await this.loadThemes();
// If active theme is found, apply it
if (this._savedThemeFileName) {
const theme = this._themes.find(t => t.fileName === this._savedThemeFileName);
if (theme) {
this.applyTheme(theme, false);
} else {
// Stored theme file not found
this._savedThemeFileName = null;
this._activeThemeFileName = null;
}
}
console.log(`[BetterCF] Theme manager ready (${this._themes.length} theme(s) found). Active: ${this._savedThemeFileName || 'None'}`);
},
_injectBaseStyles() {
if (document.getElementById('bcf-custom-themes-style')) return;
const style = document.createElement('style');
style.id = 'bcf-custom-themes-style';
style.textContent = `
/* ═══ BetterCurseForge Settings Themes UI ═══ */
#bcf-custom-themes-section {
margin-top: 0 !important;
padding: 0 !important;
}
.bcf-themes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.bcf-heading {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 24px;
color: var(--color-text-title, currentColor);
}
.bcf-heading > span:first-child {
display: inline-flex;
align-items: center;
}
.bcf-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: #a78bfa;
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 3px 8px;
border-radius: 10px;
letter-spacing: 0.3px;
}
.bcf-actions {
display: flex;
gap: 8px;
align-items: center;
}
.bcf-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
height: 28px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--color-text-primary, currentColor);
background: var(--surface-on-surface, rgba(255, 255, 255, 0.08));
border: 1px solid var(--border-tertiery, rgba(255, 255, 255, 0.15));
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.bcf-btn svg {
display: block;
flex-shrink: 0;
width: 13px;
height: 13px;
}
.bcf-btn:hover {
background: var(--surface-on-surface-hover, rgba(255, 255, 255, 0.16));
border-color: rgba(255, 255, 255, 0.3);
color: var(--color-text-title-hover, #ffffff);
}
.bcf-btn:active {
transform: scale(0.97);
}
.bcf-theme-thumb {
border-radius: 0;
}
.bcf-theme-thumb .thumb-icon svg {
display: block;
}
.bcf-theme-thumb.is-active .sprite-icon {
opacity: 1 !important;
color: #ffffff !important;
}
/* ═══ Mutual Exclusivity ═══ */
/* When a BetterCF client theme is active, hide active checkmark & active indicator on native theme cards */
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .sprite-icon {
opacity: 0 !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .thumb-icon {
background-color: transparent !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active {
outline: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* Neutralize native skin styles and title bar */
html.bcf-custom-theme-active .title-bar,
html.bcf-custom-theme-active [class*="title-bar"],
html.bcf-custom-theme-active [class*="TitleBar"] {
background: #0d0818 !important;
--title-bar-background: #0d0818 !important;
}
html.bcf-custom-theme-active .pixelforge-skin,
html.bcf-custom-theme-active .lifeforge-skin,
html.bcf-custom-theme-active .fantasyforge-skin,
html.bcf-custom-theme-active .halloween-skin,
html.bcf-custom-theme-active .cf-light,
html.bcf-custom-theme-active .cf-light-colorblind {
--title-bar-background: #0d0818 !important;
--surface-brand-brand: #8b5cf6 !important;
--surface-brand-brand-hover: #9d74f7 !important;
--accent-font: var(--body-font, "Lato", sans-serif) !important;
background-color: transparent !important;
}
html.bcf-custom-theme-active .pixelforge-skin :not(.highlighted-project-container) .card:after {
display: none !important;
}
`;
(document.head || document.documentElement).appendChild(style);
},
async loadThemes() {
if (!ipc) return [];
try {
this._themes = await ipc.invoke('bcf:themes:list');
} catch (err) {
console.error('[BetterCF] Failed to load themes:', err);
this._themes = [];
}
return this._themes;
},
applyTheme(theme, save = true) {
if (!theme || !theme.css) return;
if (!this._styleElement) {
this._styleElement = document.createElement('style');
this._styleElement.id = 'bcf-custom-theme';
(document.head || document.documentElement).appendChild(this._styleElement);
}
this._styleElement.disabled = false;
this._styleElement.textContent = theme.css;
this._styleElement.dataset.bcfTheme = theme.fileName;
this._activeThemeFileName = theme.fileName;
document.documentElement.classList.add('bcf-custom-theme-active');
const ALL_SKIN_CLASSES = [
'cf-light',
'cf-light-colorblind',
'pixelforge-skin',
'lifeforge-skin',
'fantasyforge-skin',
'halloween-skin',
];
const app = document.querySelector('.cf-app');
if (app) {
app.classList.remove(...ALL_SKIN_CLASSES);
if (!app.classList.contains('cf-classic')) {
app.classList.add('cf-classic');
}
}
if (save) {
this._savedThemeFileName = theme.fileName;
this._isPreviewing = false;
try {
localStorage.setItem('bcf_active_custom_theme', theme.fileName);
ipc?.invoke?.('bcf:settings:set', 'activeCustomTheme', theme.fileName);
} catch {}
}
console.log(`[BetterCF] Applied custom theme: ${theme.name} (${theme.fileName}) [save=${save}]`);
},
removeTheme(save = true) {
if (this._styleElement) {
this._styleElement.remove();
this._styleElement = null;
}
this._activeThemeFileName = null;
document.documentElement.classList.remove('bcf-custom-theme-active');
if (save) {
this._savedThemeFileName = null;
this._isPreviewing = false;
try {
localStorage.removeItem('bcf_active_custom_theme');
ipc?.invoke?.('bcf:settings:set', 'activeCustomTheme', null);
} catch {}
}
console.log(`[BetterCF] Reverted to native theme [save=${save}].`);
},
previewTheme(theme) {
if (!theme || !theme.css) return;
if (this._savedThemeFileName === theme.fileName) return;
this._isPreviewing = true;
this.applyTheme(theme, false);
},
cancelPreview() {
if (!this._isPreviewing) return;
this._isPreviewing = false;
if (this._savedThemeFileName) {
const saved = this._themes.find(t => t.fileName === this._savedThemeFileName);
if (saved) {
this.applyTheme(saved, false);
return;
}
}
this.removeTheme(false);
},
suspendForNativePreview() {
if (this._isSuspendedForNative) return;
if (this._savedThemeFileName || this._activeThemeFileName) {
this._isSuspendedForNative = true;
if (this._styleElement) {
this._styleElement.disabled = true;
}
document.documentElement.classList.remove('bcf-custom-theme-active');
}
},
resumeFromNativePreview() {
if (!this._isSuspendedForNative) return;
this._isSuspendedForNative = false;
if (this._savedThemeFileName) {
if (this._styleElement) {
this._styleElement.disabled = false;
}
document.documentElement.classList.add('bcf-custom-theme-active');
}
},
getActiveThemeName() {
return this._savedThemeFileName;
},
getThemes() {
return [...this._themes];
},
};
// ═══════════════════════════════════════════════════════════════
// NATIVE SETTINGS -> THEMES HOOK & MUTUAL EXCLUSIVITY
// ═══════════════════════════════════════════════════════════════
let _isSwitchingThemeInternally = false;
function setupSettingsThemesHook() {
let isInjecting = false;
// Listen for clicks on native theme cards to ensure mutual exclusivity:
// If user clicks a native theme card, deactivate any client theme.
document.addEventListener('click', (e) => {
if (_isSwitchingThemeInternally) return;
const nativeThumb = e.target.closest('.theme-thumb:not(.bcf-theme-thumb)');
if (nativeThumb) {
ThemeManager._isSuspendedForNative = false;
if (ThemeManager.getActiveThemeName()) {
ThemeManager.removeTheme(true);
const grid = document.querySelector('#bcf-custom-themes-grid');
if (grid) {
grid.querySelectorAll('.bcf-theme-thumb').forEach(c => c.classList.remove('is-active'));
}
}
}
}, true);
// When hovering over native theme cards, suspend client theme so native hover preview works!
document.addEventListener('mouseover', (e) => {
const nativeThumb = e.target.closest('.theme-thumb:not(.bcf-theme-thumb)');
if (nativeThumb) {
ThemeManager.suspendForNativePreview();
}
}, true);
document.addEventListener('mouseout', (e) => {
const nativeThumb = e.target.closest('.theme-thumb:not(.bcf-theme-thumb)');
if (nativeThumb) {
const toThumb = e.relatedTarget?.closest?.('.theme-thumb:not(.bcf-theme-thumb)');
if (!toThumb) {
ThemeManager.resumeFromNativePreview();
}
}
}, true);
const checkAndInject = async () => {
const view = document.querySelector('.settings-themes-view');
if (!view) return;
if (view.querySelector('#bcf-custom-themes-section')) {
return;
}
if (isInjecting) return;
isInjecting = true;
try {
await renderCustomThemesSection(view);
} finally {
isInjecting = false;
}
};
// Initial check
checkAndInject();
// Observe DOM mutations for when the user navigates to Settings -> Themes
const observer = new MutationObserver(() => {
const view = document.querySelector('.settings-themes-view');
if (view && !view.querySelector('#bcf-custom-themes-section')) {
checkAndInject();
} else if (!view) {
ThemeManager.cancelPreview();
ThemeManager.resumeFromNativePreview();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
window.addEventListener('hashchange', () => setTimeout(checkAndInject, 100));
window.addEventListener('popstate', () => setTimeout(checkAndInject, 100));
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
async function renderCustomThemesSection(view) {
if (view.querySelector('#bcf-custom-themes-section')) return;
const section = document.createElement('div');
section.className = 'settings-item-row';
section.id = 'bcf-custom-themes-section';
section.innerHTML = `
<div class="horizontal-divider"></div>
<div class="bcf-themes-header">
<h2 class="bcf-heading">
<span>Client Themes</span>
<span class="bcf-tag">BetterCurseForge</span>
</h2>
<div class="bcf-actions">
<button class="bcf-btn" id="bcf-open-folder-btn" type="button" title="Open themes folder">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5C2 4.44772 2.44772 4 3 4H9.61257C10.043 4 10.4251 4.27543 10.5613 4.68377L11 6H2V5Z"/>
<path d="M2 8V19C2 19.5523 2.44772 20 3 20H21C21.5523 20 22 19.5523 22 19V9C22 8.44772 21.5523 8 21 8H2Z"/>
</svg>
<span>Open Folder</span>
</button>
<button class="bcf-btn" id="bcf-refresh-themes-btn" type="button" title="Reload custom themes">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<g transform="translate(2, 2)">
<path d="M10 4C6.68629 4 4 6.68629 4 10H2C2 5.58172 5.58172 2 10 2C12.261 2 14.3029 2.93795 15.7579 4.44597L17.1314 2.94754C17.4396 2.61139 18 2.8294 18 3.2854V7.5C18 7.69203 17.8918 7.85877 17.7329 7.94253C17.738 7.96166 17.743 7.98082 17.748 8L13.6366 8C13.2016 8 12.9741 7.48284 13.268 7.16214L14.403 5.92399C13.3071 4.7407 11.74 4 10 4Z"/>
<path d="M10 16C13.3137 16 16 13.3137 16 10H18C18 14.4183 14.4183 18 10 18C7.73902 18 5.69711 17.062 4.24214 15.554L2.86858 17.0525C2.56044 17.3886 2 17.1706 2 16.7146V12.5C2 12.308 2.10825 12.1412 2.26705 12.0575C2.26198 12.0383 2.25697 12.0192 2.25204 12H6.36338C6.79843 12 7.02594 12.5172 6.73196 12.8379L5.59699 14.076C6.69293 15.2593 8.25997 16 10 16Z"/>
</g>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<div class="themes-list" id="bcf-custom-themes-grid"></div>
`;
// Insert section right after existing settings-item-row or before upsell
const rows = view.querySelectorAll('.settings-item-row:not(#bcf-custom-themes-section)');
if (rows.length > 0) {
const lastRow = rows[rows.length - 1];
lastRow.after(section);
} else {
const upsell = view.querySelector('.upsell') || view.querySelector('[class*="upsell"]');
if (upsell) {
view.insertBefore(section, upsell);
} else {
view.appendChild(section);
}
}
// Connect buttons
section.querySelector('#bcf-open-folder-btn')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
ipc?.invoke?.('bcf:open-themes-folder');
});
section.querySelector('#bcf-refresh-themes-btn')?.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const btn = section.querySelector('#bcf-refresh-themes-btn');
if (btn) btn.style.opacity = '0.5';
await ThemeManager.loadThemes();
populateThemeCards(section);
if (btn) btn.style.opacity = '1';
});
populateThemeCards(section);
}
function populateThemeCards(section) {
const grid = section.querySelector('#bcf-custom-themes-grid');
if (!grid) return;
grid.innerHTML = '';
const themes = ThemeManager.getThemes();
const activeFileName = ThemeManager.getActiveThemeName();
if (themes.length === 0) {
const empty = document.createElement('div');
empty.className = 'bcf-empty-msg';
empty.style.cssText = 'color: var(--color-text-secondary, #71717a); font-size: 13px; padding: 10px 0;';
empty.textContent = 'No custom themes found in themes folder. Click "Open Folder" to add .css themes.';
grid.appendChild(empty);
return;
}
for (const theme of themes) {
const isThisActive = activeFileName === theme.fileName;
const card = document.createElement('div');
card.className = `theme-thumb bcf-theme-thumb ${isThisActive ? 'is-active' : ''}`;
card.dataset.themeFile = theme.fileName;
const primary = theme.primaryColor || '#8b5cf6';
const title = theme.titleColor || '#a78bfa';
const body = theme.bodyColor || '#2e1065';
const bg = theme.backgroundColor || '#161026';
card.style.setProperty('--color-thumb-primary', primary);
card.style.setProperty('--color-thumb-title', title);
card.style.setProperty('--color-thumb-body', body);
card.style.setProperty('--color-thumb-name', '#f1f5f9');
card.style.setProperty('--color-thumb-icon-bg', primary);
card.style.setProperty('--color-thumb-check-color', '#ffffff');
card.style.backgroundColor = bg;
card.innerHTML = `
<div class="primary"></div>
<div class="title"></div>
<div class="body"></div>
<div class="name" title="${escapeHtml(theme.description || theme.name)}">${escapeHtml(theme.name)}</div>
<div class="thumb-icon">
<svg class="sprite-icon" aria-hidden="true" focusable="false">
<use href="../../assets/images/sprite.svg#icon-check"></use>
</svg>
</div>
`;
card.addEventListener('mouseenter', () => {
ThemeManager.previewTheme(theme);
});
card.addEventListener('mouseleave', () => {
ThemeManager.cancelPreview();
});
card.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (ThemeManager.getActiveThemeName() === theme.fileName) {
// Toggle off -> revert to native theme
ThemeManager.removeTheme(true);
card.classList.remove('is-active');
} else {
// Activate this client theme
_isSwitchingThemeInternally = true;
try {
// Ensure CurseForge native theme switches to Classic Dark baseline so no other skin remains active
const classicCard = [...document.querySelectorAll('.settings-themes-view .theme-thumb:not(.bcf-theme-thumb)')]
.find(c => {
const txt = c.querySelector('.name')?.textContent?.trim().toLowerCase() || '';
return txt === 'curseforge classic' || (txt.includes('classic') && !txt.includes('colorblind'));
});
if (classicCard && !classicCard.classList.contains('is-active')) {
classicCard.click();
}
} catch (err) {
console.warn('[BetterCF] Error switching base theme:', err);
} finally {
_isSwitchingThemeInternally = false;
}
ThemeManager.applyTheme(theme, true);
grid.querySelectorAll('.bcf-theme-thumb').forEach(c => {
c.classList.toggle('is-active', c.dataset.themeFile === theme.fileName);
});
}
});
grid.appendChild(card);
}
}
// ═══════════════════════════════════════════════════════════════
// PLUGIN SYSTEM
// ═══════════════════════════════════════════════════════════════
const PluginManager = {
_plugins: [],
_instances: new Map(),
_styleElements: new Map(),
async init() {
if (!ipc) return;
try {
this._plugins = await ipc.invoke('bcf:plugins:list');
for (const plugin of this._plugins) {
if (plugin.enabled) {
this.enablePlugin(plugin);
}
}
console.log(`[BetterCF] Plugin manager ready. ${this._plugins.length} plugin(s) found.`);
} catch (e) {
console.error('[BetterCF] Failed to initialize plugins:', e);
}
},
enablePlugin(plugin) {
if (this._instances.has(plugin.dirName)) return;
try {
if (plugin.styles) {
const style = document.createElement('style');
style.id = `bcf-plugin-style-${plugin.dirName}`;
style.textContent = plugin.styles;
(document.head || document.documentElement).appendChild(style);
this._styleElements.set(plugin.dirName, style);
}
if (plugin.script) {
const instance = { enabled: true };
const pluginFn = new Function('BetterCurseForge', 'plugin', plugin.script);
pluginFn(window.BetterCurseForge, instance);
this._instances.set(plugin.dirName, instance);
console.log(`[BetterCF] Plugin loaded: ${plugin.name}`);
}
} catch (err) {
console.error(`[BetterCF] Failed to load plugin ${plugin.name}:`, err);
}
},
disablePlugin(plugin) {
const instance = this._instances.get(plugin.dirName);
if (instance?.onDisable) {
try {
instance.onDisable();
} catch (err) {
console.error(`[BetterCF] Error disabling plugin ${plugin.name}:`, err);
}
}
this._instances.delete(plugin.dirName);
const style = this._styleElements.get(plugin.dirName);
if (style) {
style.remove();
this._styleElements.delete(plugin.dirName);
}
},
async togglePlugin(dirName) {
if (!ipc) return false;
const enabled = await ipc.invoke('bcf:settings:toggle-plugin', dirName);
const plugin = this._plugins.find(p => p.dirName === dirName);
if (!plugin) return false;
plugin.enabled = enabled;
if (enabled) {
this.enablePlugin(plugin);
} else {
this.disablePlugin(plugin);
}
return enabled;
},
getPlugins() {
return [...this._plugins];
},
};
// ═══════════════════════════════════════════════════════════════
// PUBLIC API — window.BetterCurseForge
// ═══════════════════════════════════════════════════════════════
const _injectedCSS = new Map();
let _cssCounter = 0;
const _eventHandlers = {};
window.BetterCurseForge = {
version: '1.0.0',
injectCSS(css) {
const id = `bcf-injected-${_cssCounter++}`;
const style = document.createElement('style');
style.id = id;
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
_injectedCSS.set(id, style);
return () => {
style.remove();
_injectedCSS.delete(id);
};
},
settings: {
async get(key) {
if (!ipc) return null;
const all = await ipc.invoke('bcf:settings:get');
return key ? all[key] : all;
},
async set(key, value) {
if (!ipc) return false;
return ipc.invoke('bcf:settings:set', key, value);
},
},
patcher: {
_patches: [],
before(obj, method, callback) {
const original = obj[method];
obj[method] = function (...args) {
callback(args);
return original.apply(this, args);
};
const unpatch = () => { obj[method] = original; };
this._patches.push(unpatch);
return unpatch;
},
after(obj, method, callback) {
const original = obj[method];
obj[method] = function (...args) {
const result = original.apply(this, args);
callback(args, result);
return result;
};
const unpatch = () => { obj[method] = original; };
this._patches.push(unpatch);
return unpatch;
},
instead(obj, method, callback) {
const original = obj[method];
obj[method] = function (...args) {
return callback(args, original.bind(this));
};
const unpatch = () => { obj[method] = original; };
this._patches.push(unpatch);
return unpatch;
},
},
on(event, callback) {
if (!_eventHandlers[event]) _eventHandlers[event] = [];
_eventHandlers[event].push(callback);
},
off(event, callback) {
if (!_eventHandlers[event]) return;
_eventHandlers[event] = _eventHandlers[event].filter(cb => cb !== callback);
},
emit(event, ...args) {
if (!_eventHandlers[event]) return;
for (const cb of _eventHandlers[event]) {
try { cb(...args); } catch (err) {
console.error(`[BetterCF] Event handler error (${event}):`, err);
}
}
},
themes: ThemeManager,
plugins: PluginManager,
ads: AdBlocker,
};
// ═══════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════
async function init() {
console.log('[BetterCF] Initializing renderer runtime...');
// 1. Block ads
AdBlocker.init();
// 2. Initialize theme manager
await ThemeManager.init();
// 3. Setup Native Settings -> Themes Hook
setupSettingsThemesHook();
// 4. Initialize plugins
await PluginManager.init();
// 5. Emit ready
window.BetterCurseForge.emit('ready');
console.log('[BetterCF] ✓ BetterCurseForge loaded successfully!');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 300));
} else {
setTimeout(init, 300);
}
})();

View file

@ -0,0 +1,65 @@
// Compact Mode Plugin for BetterCurseForge
// Reduces padding and spacing for a denser UI layout
const removeCSS = BetterCurseForge.injectCSS(`
/* Reduce card padding */
[class*="card"],
[class*="Card"] {
padding: 8px !important;
margin-bottom: 6px !important;
}
/* Reduce list item spacing */
[class*="list-item"],
[class*="ListItem"],
[class*="mod-row"],
[class*="ModRow"] {
padding: 6px 12px !important;
min-height: auto !important;
}
/* Reduce header sizes */
[class*="section-header"],
[class*="SectionHeader"] {
padding: 8px 16px !important;
font-size: 14px !important;
}
/* Reduce sidebar item padding */
[class*="sidebar"] [class*="item"],
[class*="Sidebar"] [class*="Item"],
nav a, nav button {
padding: 6px 12px !important;
}
/* Reduce gap between elements */
[class*="grid"],
[class*="Grid"] {
gap: 8px !important;
}
/* Smaller mod thumbnails */
[class*="thumbnail"],
[class*="Thumbnail"],
[class*="mod-icon"],
[class*="ModIcon"] {
width: 40px !important;
height: 40px !important;
}
/* Reduce page padding */
[class*="page-content"],
[class*="PageContent"],
[class*="content-area"],
[class*="ContentArea"] {
padding: 12px !important;
}
`);
// Plugin lifecycle
plugin.onDisable = () => {
removeCSS();
console.log('[Compact Mode] Disabled.');
};
console.log('[Compact Mode] Enabled — UI is now more compact.');

View file

@ -0,0 +1,6 @@
{
"name": "Compact Mode",
"author": "BetterCF Team",
"description": "Reduces padding and spacing throughout the UI for a more compact layout",
"version": "1.0.0"
}

View file

@ -0,0 +1,215 @@
/* BetterCurseForge — Purple Night Theme */
/* A rich, sleek, fully-integrated dark purple theme for CurseForge Launcher */
:root,
.cf-app,
.base-skin,
.dark-skin,
.cf-classic,
.theme-purple-night {
/* ── Accent & Brand ── */
--surface-brand-brand: #8b5cf6 !important;
--surface-brand-brand-hover: #9d74f7 !important;
--color-primary: #8b5cf6 !important;
--color-primary-hover: #9d74f7 !important;
--color-primary-active: #7c3aed !important;
--color-accent: #a78bfa !important;
--color-highlight: #8b5cf6 !important;
--border-primary: #8b5cf6 !important;
--border-highlight: #8b5cf6 !important;
--selected-item-border: 3px solid #8b5cf6 !important;
/* ── Backgrounds & Surfaces ── */
--color-background: #0d0818 !important;
--surface-base: #07040d !important;
--surface-base-half: rgba(7, 4, 13, 0.5) !important;
--surface-primary: #110b20 !important;
--surface-secondary: #170e2b !important;
--surface-tertiery: #21153d !important;
--surface-tertiery-hover: #2c1c52 !important;
--surface-quartinery: #382468 !important;
--surface-on-surface: rgba(255, 255, 255, 0.08) !important;
--surface-on-surface-hover: rgba(255, 255, 255, 0.14) !important;
/* ── Cards (Modpack / Project) ── */
--surface-card: #170e2b !important;
--surface-card-hover: #221540 !important;
--color-surface-card: #170e2b !important;
--color-surface-card-hover: #221540 !important;
--color-surface-card-active: #2b1b50 !important;
--color-surface-card-active-hover: #352163 !important;
--color-surface-card-disabled: #100a1e !important;
--color-surface-info-block: #21153d !important;
--color-surface-folder-view-item: #21153d !important;
/* ── Tables & Lists ── */
--color-surface-table: #170e2b !important;
--color-surface-table-hover: #21153d !important;
--color-surface-table-head: #1c1134 !important;
--color-surface-table-head-hover: #261747 !important;
/* ── Modals & Dialogs ── */
--color-surface-modal: #170e2b !important;
--color-surface-modal-footer: #110b20 !important;
--color-modal-border: #2c1c52 !important;
--color-modal-backdrop: rgba(7, 4, 13, 0.85) !important;
--color-modal-backdrop-low: rgba(7, 4, 13, 0.5) !important;
/* ── Title Bar ── */
--title-bar-background: linear-gradient(90deg, #110b20 0%, #1a1032 50%, #110b20 100%) !important;
--btn-window-action: #a78bfa !important;
--btn-window-action-hover: #2c1c52 !important;
/* ── Typography & Text ── */
--text-pure: #ffffff !important;
--text-primary: #f1f5f9 !important;
--color-text-title: #f8fafc !important;
--color-text-title-hover: #ffffff !important;
--color-text-primary: #e2e8f0 !important;
--color-text-body: #cbd5e1 !important;
--color-text-secondary: #94a3b8 !important;
--color-text-secondary-icon: #94a3b8 !important;
--color-text-dropdown: #e2e8f0 !important;
--color-text-tooltip: #ede9fe !important;
/* ── Buttons ── */
--btn-primary-text: #ffffff !important;
--btn-secondary-hover: rgba(139, 92, 246, 0.16) !important;
--btn-secondary-border: rgba(139, 92, 246, 0.35) !important;
--btn-clean-hover: #21153d !important;
--btn-tertiary-background: #21153d !important;
--btn-tertiary-hover-background: #2c1c52 !important;
--btn-tertiary-text: #f1f5f9 !important;
--btn-tertiary-hover-text: #ffffff !important;
/* ── Borders & Dividers ── */
--border-tertiery: rgba(139, 92, 246, 0.18) !important;
--color-divider-1: #21153d !important;
--color-divider-2: #2c1c52 !important;
/* ── Scrollbars ── */
--color-scrollbar-thumb: #3b2266 !important;
/* ── Theme Thumbnail Preview ── */
--color-thumb-primary: #8b5cf6 !important;
--color-thumb-title: #3b2266 !important;
--color-thumb-body: #241442 !important;
--color-thumb-name: #ffffff !important;
--color-thumb-icon-bg: #8b5cf6 !important;
--color-thumb-check-color: #ffffff !important;
}
/* ── Element-Specific Polish ── */
/* Title bar */
.title-bar,
[class*="title-bar"],
[class*="TitleBar"] {
background: var(--title-bar-background) !important;
}
/* Sidenav / left navigation */
.sidenav,
[class*="sidenav"] {
background-color: var(--surface-secondary) !important;
}
/* Sidebar game icon active indicator bar */
.games-side-menu li a:after {
background-color: var(--surface-brand-brand) !important;
}
/* User profile button & avatar border */
#user-login-menu .user-profile-button:before,
.user-profile-button:before {
border-color: var(--surface-brand-brand) !important;
}
#user-login-menu .user-profile-button:hover:before,
#user-login-menu.is-open .user-profile-button:before,
.user-profile-button:hover:before {
border-color: var(--surface-brand-brand-hover) !important;
}
/* Sidebar active tab indicator (settings, etc.) */
.system-side-menu ul.user-menu .user-menu-item > a.tab.is-active:before,
.system-side-menu ul.user-menu .user-menu-item > button.tab.is-active:before,
.system-side-menu ul.user-menu .user-menu-item > .subscription-tooltip > a.tab.is-active:before {
border-left-color: var(--surface-brand-brand) !important;
}
.system-side-menu ul.user-menu .user-menu-item > a.tab.is-active,
.system-side-menu ul.user-menu .user-menu-item > button.tab.is-active {
color: var(--surface-brand-brand) !important;
}
/* Active tab indicator and line in horizontal tabs */
.tabs-menu ul .line,
.tabs-menu ul li a:before,
.tabs-menu ul li .tab-control:before {
background-color: var(--surface-brand-brand) !important;
}
.tabs-menu ul li a.is-active,
.tabs-menu ul li.is-active a {
color: var(--color-text-title, #ffffff) !important;
}
/* Notification badges and dots */
.highlight-indicator {
background-color: #8b5cf6 !important;
box-shadow: 0 0 6px rgba(139, 92, 246, 0.6) !important;
}
.badge-active:before,
.count-badge-wrapper .count-badge,
.popover-button button.btn-clean.btn-with-icon.has-badge:after {
background: var(--surface-brand-brand) !important;
}
/* Primary buttons */
.btn-primary:not(.btn-secondary),
button[class*="primary"]:not([class*="secondary"]) {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important;
border: none !important;
color: #ffffff !important;
}
.btn-primary:not(.btn-secondary):hover,
button[class*="primary"]:not([class*="secondary"]):hover {
background: linear-gradient(135deg, #9d74f7 0%, #8b5cf6 100%) !important;
}
/* Modpack card typography */
.tile-view-item .title,
.tile-view-item .title .name-container .mod-name {
color: var(--color-text-title, #ffffff) !important;
}
.tile-view-item .author {
color: var(--color-text-secondary, #94a3b8) !important;
}
/* Modpack card version badge */
.tile-view-item .art .modpack-version:before {
background-color: rgba(23, 14, 43, 0.85) !important;
}
.tile-view-item .art .modpack-version {
border-radius: 4px;
}
/* Selection */
::selection {
background: rgba(139, 92, 246, 0.4) !important;
color: #ffffff !important;
}
/* Scrollbars */
::-webkit-scrollbar-track {
background: var(--surface-base);
}
::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #52318c;
}

View file

@ -0,0 +1,10 @@
{
"name": "Purple Night",
"author": "BetterCF Team",
"description": "A sleek purple-tinted dark theme for CurseForge",
"version": "1.0.0",
"primaryColor": "#8b5cf6",
"titleColor": "#a78bfa",
"bodyColor": "#2e1065",
"backgroundColor": "#130e20"
}

81
docs/.vitepress/config.ts Normal file
View file

@ -0,0 +1,81 @@
import { defineConfig } from 'vitepress';
export default defineConfig({
title: 'BetterCurseForge',
description: 'Client mod for CurseForge Launcher',
appearance: 'dark',
lastUpdated: true,
cleanUrls: true,
head: [
['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' }],
['meta', { name: 'og:description', content: 'Client mod for CurseForge Launcher — Ad blocking, custom themes, and powerful plugin API.' }],
],
themeConfig: {
siteTitle: 'BetterCurseForge',
nav: [
{ text: 'Guide', link: '/guide/installation' },
{ text: 'API', link: '/api/' },
{ text: 'GitHub', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
],
sidebar: [
{
text: 'Getting Started',
collapsed: false,
items: [
{ text: 'Installation', link: '/guide/installation' },
],
},
{
text: 'Themes',
collapsed: false,
items: [
{ text: 'Custom Themes', link: '/guide/themes' },
],
},
{
text: 'Plugins',
collapsed: false,
items: [
{ text: 'Plugin Development', link: '/guide/plugins' },
],
},
{
text: 'API Reference',
collapsed: false,
items: [
{ text: 'Full API Reference', link: '/api/' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/BetterCurseForge/BetterCurseForge' },
],
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2026 BetterCurseForge Contributors',
},
search: {
provider: 'local',
},
outline: {
level: [2, 3],
label: 'On this page',
},
docFooter: {
prev: 'Previous page',
next: 'Next page',
},
},
});

View file

@ -0,0 +1,38 @@
/* BetterCurseForge Documentation Theme - Purple Accent */
:root {
--vp-c-brand-1: #9333ea;
--vp-c-brand-2: #a855f7;
--vp-c-brand-3: #7e22ce;
--vp-c-brand-soft: rgba(168, 85, 247, 0.14);
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #a855f7 30%, #ec4899);
--vp-home-hero-image-background-image: linear-gradient(-45deg, #9333ea 30%, #ec4899);
--vp-home-hero-image-filter: blur(44px);
}
.dark {
--vp-c-brand-1: #a855f7;
--vp-c-brand-2: #c084fc;
--vp-c-brand-3: #9333ea;
--vp-c-brand-soft: rgba(168, 85, 247, 0.2);
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #c084fc 25%, #f472b6);
--vp-home-hero-image-background-image: linear-gradient(-45deg, #a855f7 30%, #f472b6);
--vp-home-hero-image-filter: blur(56px);
}
/* Custom styling enhancements */
.custom-block.tip {
border-color: var(--vp-c-brand-1);
}
.vp-doc a {
text-decoration-color: var(--vp-c-brand-soft);
text-underline-offset: 3px;
}
.vp-doc a:hover {
text-decoration-color: var(--vp-c-brand-1);
}

View file

@ -0,0 +1,6 @@
import DefaultTheme from 'vitepress/theme';
import './custom.css';
export default {
extends: DefaultTheme,
};

261
docs/api/index.md Normal file
View file

@ -0,0 +1,261 @@
# API Reference
BetterCurseForge exposes a global runtime object on `window.BetterCurseForge` within the CurseForge Electron renderer process. This API allows themes, plugins, and developer scripts to inject styles, monkey-patch methods, manage settings, and listen to application events.
---
## TypeScript Definition
For TypeScript development and autocompletion:
```typescript
declare global {
interface Window {
BetterCurseForge: BetterCurseForgeAPI;
}
}
export interface BetterCurseForgeAPI {
/** Active BetterCurseForge runtime version */
readonly version: string;
/** Injects CSS dynamically into <head>. Returns an unmount callback. */
injectCSS(css: string): () => void;
/** Persistent settings storage */
settings: {
get(key?: string): Promise<any>;
set(key: string, value: any): Promise<boolean>;
};
/** Runtime method interception and monkey-patching */
patcher: {
before(obj: any, method: string, callback: (args: any[]) => void): () => void;
after(obj: any, method: string, callback: (args: any[], result: any) => any): () => void;
instead(obj: any, method: string, callback: (args: any[], original: Function) => any): () => void;
};
/** Event bus */
on(event: string, callback: (...args: any[]) => void): void;
off(event: string, callback: (...args: any[]) => void): void;
emit(event: string, ...args: any[]): void;
/** Internal subsystem managers */
themes: ThemeManager;
plugins: PluginManager;
ads: AdBlocker;
}
```
---
## Core API
### `BetterCurseForge.version`
- **Type**: `string`
- **Description**: The currently running BetterCurseForge runtime version.
```javascript
console.log(BetterCurseForge.version); // "1.0.0"
```
---
### `BetterCurseForge.injectCSS(css)`
Dynamically injects CSS rules into the document `<head>`.
- **Parameters**:
- `css` *(string)*: Raw CSS string to inject.
- **Returns**: `() => void` — Function that removes the injected `<style>` tag when called.
```javascript
// Inject styles
const uninject = BetterCurseForge.injectCSS(`
.some-container {
background-color: #1a102f !important;
}
`);
// Later, remove the styles
uninject();
```
---
## Settings API (`BetterCurseForge.settings`)
Manages persistent configuration stored in `%APPDATA%\CurseForge\BetterCurseForge\settings.json`.
### `settings.get(key?)`
Retrieves a setting by key, or returns the entire settings object if no key is specified.
- **Parameters**:
- `key` *(string, optional)*: Setting key name.
- **Returns**: `Promise<any>` — Value of the setting, or `null`.
```javascript
// Retrieve a specific value
const activeTheme = await BetterCurseForge.settings.get('activeCustomTheme');
// Retrieve all settings
const allSettings = await BetterCurseForge.settings.get();
```
### `settings.set(key, value)`
Stores a setting value and writes it asynchronously to disk.
- **Parameters**:
- `key` *(string)*: Setting key name.
- `value` *(any)*: JSON-serializable value.
- **Returns**: `Promise<boolean>` — `true` if saved successfully.
```javascript
await BetterCurseForge.settings.set('myPlugin.fontSize', 14);
```
---
## Patcher API (`BetterCurseForge.patcher`)
Provides monkey-patching utilities to inspect, modify, or replace methods on JavaScript objects and modules safely.
### `patcher.before(obj, method, callback)`
Executes `callback(args)` **before** the target method is invoked.
- **Parameters**:
- `obj` *(object)*: Target object.
- `method` *(string)*: Method name on `obj`.
- `callback` *(Function)*: Function receiving arguments: `(args) => void`.
- **Returns**: `() => void` — Unpatch function that restores the original method.
```javascript
const unpatch = BetterCurseForge.patcher.before(console, 'warn', (args) => {
console.log('[Warn Interceptor]', ...args);
});
// Revert patch:
unpatch();
```
---
### `patcher.after(obj, method, callback)`
Executes `callback(args, result)` **after** the target method runs. You can return a new value to replace the method's return value.
- **Parameters**:
- `obj` *(object)*: Target object.
- `method` *(string)*: Method name on `obj`.
- `callback` *(Function)*: `(args, result) => any`.
- **Returns**: `() => void` — Unpatch function.
```javascript
const unpatch = BetterCurseForge.patcher.after(service, 'getTitle', (args, result) => {
return `Prefix: ${result}`;
});
unpatch();
```
---
### `patcher.instead(obj, method, callback)`
Completely overrides the target method. Receives both the incoming arguments and a reference to the original method.
- **Parameters**:
- `obj` *(object)*: Target object.
- `method` *(string)*: Method name on `obj`.
- `callback` *(Function)*: `(args, originalFn) => any`.
- **Returns**: `() => void` — Unpatch function.
```javascript
const unpatch = BetterCurseForge.patcher.instead(target, 'openExternalUrl', (args, originalFn) => {
const url = args[0];
if (url.startsWith('https://safe-domain.com')) {
return originalFn(...args);
}
console.warn('Blocked external URL navigation:', url);
});
unpatch();
```
---
## Event System (`BetterCurseForge.on / off / emit`)
BetterCurseForge includes an event bus for inter-plugin and lifecycle communication.
### `BetterCurseForge.on(event, callback)`
Subscribes a listener to an event.
```javascript
const onReady = () => console.log('BetterCurseForge is ready!');
BetterCurseForge.on('ready', onReady);
```
### `BetterCurseForge.off(event, callback)`
Unsubscribes a listener.
```javascript
BetterCurseForge.off('ready', onReady);
```
### `BetterCurseForge.emit(event, ...args)`
Emits an event to all registered listeners.
```javascript
BetterCurseForge.emit('custom-event', { detail: 123 });
```
---
## Subsystem Managers
### `BetterCurseForge.themes` (`ThemeManager`)
Provides programmatic control over custom themes:
```javascript
// Get all discovered themes
const themes = BetterCurseForge.themes.getThemes();
// Get active theme filename (or null)
const currentTheme = BetterCurseForge.themes.getActiveThemeName();
// Preview a theme temporarily on screen
BetterCurseForge.themes.previewTheme(themes[0]);
// Revert preview back to active theme
BetterCurseForge.themes.cancelPreview();
// Apply and save a theme permanently
BetterCurseForge.themes.applyTheme(themes[0], true);
// Remove custom theme and revert to native CurseForge skin
BetterCurseForge.themes.removeTheme(true);
```
### `BetterCurseForge.plugins` (`PluginManager`)
Provides programmatic control over plugins:
```javascript
// Get all installed plugins
const plugins = BetterCurseForge.plugins.getPlugins();
// Toggle a plugin by folder name
await BetterCurseForge.plugins.togglePlugin('compact-mode');
```
### `BetterCurseForge.ads` (`AdBlocker`)
Internal ad blocker runtime:
```javascript
// Contains zero-ad CSS rules and ad container cleanup handlers
console.log(BetterCurseForge.ads);
```

179
docs/guide/installation.md Normal file
View file

@ -0,0 +1,179 @@
# Installation & Usage
This guide covers installing, managing, and uninstalling **BetterCurseForge** using either the desktop GUI application or the command-line interface.
---
## Prerequisites
Before using BetterCurseForge, make sure you have:
- **Windows 10 or Windows 11** (64-bit)
- **CurseForge Launcher** installed (standalone or Overwolf edition)
- CurseForge closed before running the patcher
::: tip SAFE & 100% REVERSIBLE
Before modifying any files, BetterCurseForge creates an untouched vanilla backup at `resources/app.asar.vanilla.bak`. You can cleanly revert to stock CurseForge at any time with a single click.
:::
---
## Method 1: Using the Desktop GUI (Recommended)
BetterCurseForge comes as a standalone desktop executable with a sleek, dark-themed graphical interface.
### Step 1: Download `BetterCurseForge.exe`
Download the latest executable from the official releases:
- [Download `BetterCurseForge.exe`](https://github.com/BetterCurseForge/BetterCurseForge/releases/latest)
Place it in any convenient folder (e.g., Downloads, Tools, or desktop).
### Step 2: Open BetterCurseForge
Double-click `BetterCurseForge.exe` to launch the application.
![BetterCurseForge GUI](/logo.svg){width=64 style="display:block;margin:1rem auto;"}
The application automatically:
- Detects your CurseForge installation folder from the Windows Registry.
- Checks whether your client is currently vanilla or patched.
- Displays the installed CurseForge version and installation directory.
### Step 3: Click "Patch CurseForge"
1. Click the **Patch CurseForge** button.
2. The patcher will close any running CurseForge processes, create a vanilla backup, apply all enhancements (ad removal, theme engine, premium unlocks, and plugin loader), update the executable integrity signature, and repack the application.
3. You will see live output in the log panel confirming:
```text
[+] Found CurseForge at: C:\Users\Username\AppData\Local\Programs\CurseForge Windows
[+] Disabled React ad component (eV -> return null)
[+] Disabled swap ads button
[+] Unlocked all native premium themes (Settings route)
[+] Unlocked all native premium themes (Popup route)
[+] Injected zero-ad and theme CSS rules into desktop.css
[+] Bundled BetterCurseForge runtime into asar
[+] Updated CurseForge.exe integrity signature
[*] CurseForge successfully patched! Ads removed, themes & plugins enabled.
```
4. Click **Launch CurseForge** or open your usual desktop shortcut—you're done!
---
## Method 2: Command-Line Interface (CLI)
For automated workflows, scripting, or headless environments, `BetterCurseForge.exe` provides a comprehensive CLI:
### Patching via Terminal
Open PowerShell or Command Prompt in the folder containing `BetterCurseForge.exe`:
::: code-group
```powershell [PowerShell]
.\BetterCurseForge.exe patch
```
```cmd [Command Prompt]
BetterCurseForge.exe patch
```
:::
### Checking Status
Verify whether your client is patched:
```powershell
.\BetterCurseForge.exe status
```
Output:
```text
CurseForge Status: PATCHED (BetterCurseForge v1.0.0)
Path: C:\Users\Username\AppData\Local\Programs\CurseForge Windows
ASAR Hash: Verified
```
### Unpatching
Restore your vanilla CurseForge client:
```powershell
.\BetterCurseForge.exe unpatch
```
This restores `resources/app.asar` from the vanilla backup and re-verifies original executable signatures.
---
## 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.
BetterCurseForge solves this with **Auto-Repatch**:
### Enabling Auto-Repatch in the GUI
- In the BetterCurseForge desktop application, simply toggle the **Auto-Repatch on Update** checkbox.
### Enabling Auto-Repatch via CLI
```powershell
.\BetterCurseForge.exe --auto-repatch enable
```
### How Auto-Repatch Works
1. Registers a lightweight Windows background task.
2. Detects whenever CurseForge's files are modified or updated by Overwolf/CurseForge.
3. Automatically closes the client, creates a new backup of the updated version, applies the BetterCurseForge patches, updates the binary signature, and re-launches your modded client silently.
4. To disable anytime:
```powershell
.\BetterCurseForge.exe --auto-repatch disable
```
---
## How It Works Under the Hood
Modern versions of CurseForge include **ASAR Code Integrity Verification** enforced directly inside `CurseForge.exe`:
```mermaid
flowchart TD
A["BetterCurseForge.exe"] --> B["Locate CurseForge Windows Installation"]
B --> C["Extract app.asar & Create app.asar.vanilla.bak"]
C --> D["Disable React Ad Components & Swap Ads"]
D --> E["Unlock Native Premium Themes (Pixelforge, Lifeforge, etc.)"]
E --> F["Inject Zero-Ad & Responsive CSS into desktop.css"]
F --> G["Bundle BetterCurseForge Core Runtime into asar"]
G --> H["Repack app.asar & Calculate SHA256 Header Hash"]
H --> I["Update CurseForge.exe ASAR Integrity Signature"]
I --> J["Launch Modded CurseForge with Full Permissions"]
```
1. **AST & Component Patching**: Replaces the React ad rendering module (`eV -> return null`) to ensure ad frames are never mounted in the virtual DOM.
2. **Premium Skin Unlock**: Patches the client-side plan validation routes so all 4 native CurseForge skins (*Pixelforge*, *Lifeforge*, *Fantasyforge*, *Spookyforge*) can be freely selected in **Settings -> Themes**.
3. **ASAR Signature Patching**: Calculates the cryptographic SHA256 hash of the modified ASAR archive header and writes the new signature into `CurseForge.exe`, ensuring Electron boots without security errors or corruption alerts.
---
## Troubleshooting & FAQ
::: details How do I verify BetterCurseForge is running?
1. Open CurseForge.
2. Notice the total absence of banner ads and video sponsor frames.
3. Navigate to **Settings -> Themes**: you will see the **Client Themes** section with *Purple Night* available, as well as unlocked *Premium* skins (*Pixelforge*, *Lifeforge*, etc.).
4. Press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> to open DevTools. The console will display:
```text
[BetterCF] Initializing renderer runtime...
[BetterCF] Theme manager ready (1 theme(s) found). Active: purple-night.css
[BetterCF] ✓ BetterCurseForge loaded successfully!
```
:::
::: details Antivirus false-positive warning
Because `BetterCurseForge.exe` inspects running processes (`CurseForge.exe`), accesses the Windows Registry to locate install paths, and updates executable hashes, some heuristic scanners may flag it. BetterCurseForge is completely open source and safe. You can inspect the entire Python codebase in `patcher/` and build it yourself.
:::
::: details Permission denied / EPERM error
If your CurseForge launcher is installed under `C:\Program Files`, modifying files requires Administrator permissions. Right-click `BetterCurseForge.exe` and select **Run as Administrator**.
:::

215
docs/guide/plugins.md Normal file
View file

@ -0,0 +1,215 @@
# Writing Plugins
BetterCurseForge features a lightweight plugin system that allows you to extend CurseForge Launcher functionality, manipulate the DOM, hook runtime methods, and persist configuration.
---
## Plugin Directory Structure
Plugins are stored in the BetterCurseForge user directory:
```text
%APPDATA%\CurseForge\BetterCurseForge\plugins\
```
Each plugin lives in its own dedicated subfolder:
```text
plugins/
└── compact-mode/
├── manifest.json # Required: Plugin metadata
├── index.js # Required: Entry script
└── styles.css # Optional: Companion stylesheet
```
You can open the plugins folder directly from the BetterCurseForge desktop GUI by clicking **Open Plugins Folder**.
---
## Plugin Manifest (`manifest.json`)
The `manifest.json` file declares your plugin's metadata:
```json
{
"id": "compact-mode",
"name": "Compact Mode",
"version": "1.0.0",
"description": "Reduces padding across mod lists, sidebar items, and modpack details for high-density browsing.",
"author": "ModderDev",
"main": "index.js",
"styles": "styles.css"
}
```
### Manifest Fields
| Field | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `name` | `string` | **Yes** | Display name shown in the management UI. |
| `version` | `string` | **Yes** | Semantic version string (e.g. `1.0.0`). |
| `description` | `string` | No | Brief summary of what the plugin accomplishes. |
| `author` | `string` | No | Author name or handle. |
| `main` | `string` | No | Entry JavaScript file. Defaults to `index.js`. |
| `styles` | `string` | No | Companion stylesheet injected automatically on enable. Defaults to `styles.css`. |
---
## Plugin Lifecycle & Execution
Plugin scripts are executed when enabled, receiving the `BetterCurseForge` global API and a `plugin` context object:
```javascript
// index.js
// BetterCurseForge passes (BetterCurseForge, plugin)
const { injectCSS, patcher, settings, on, off } = BetterCurseForge;
console.log('[MyPlugin] Plugin initialized!');
// Attach an onDisable cleanup handler:
plugin.onDisable = () => {
console.log('[MyPlugin] Plugin disabled! Cleaning up...');
// Remove any custom DOM elements, event listeners, or intervals here
};
```
::: tip AUTOMATIC STYLE INJECTION & REMOVAL
If your plugin folder contains a `styles.css` (or file declared in `"styles"` in `manifest.json`), BetterCurseForge automatically injects it into `<head>` when your plugin is enabled and removes it when disabled! You do not need to manually inject or clean up companion stylesheets.
:::
---
## BetterCurseForge Plugin APIs
### 1. `BetterCurseForge.injectCSS(css)`
Injects custom CSS dynamically into `<head>`. Returns a cleanup function that unmounts the style tag:
```javascript
const removeStyles = BetterCurseForge.injectCSS(`
.addon-row { padding: 4px 8px !important; }
`);
// Later in cleanup:
plugin.onDisable = () => {
removeStyles();
};
```
---
### 2. `BetterCurseForge.settings`
Stores and retrieves persistent settings saved to disk (`settings.json`):
```javascript
// Retrieve a stored setting (or null if not set)
const isCustomFont = await BetterCurseForge.settings.get('compactMode.customFont');
// Store a setting
await BetterCurseForge.settings.set('compactMode.density', 'ultra');
```
---
### 3. `BetterCurseForge.patcher`
Safe method monkey-patching for objects and functions:
#### `patcher.before(object, methodName, callback)`
Runs your callback before the target method executes. Receives `(args)`:
```javascript
const unpatch = BetterCurseForge.patcher.before(console, 'error', (args) => {
console.warn('[Intercepted Console Error]', ...args);
});
plugin.onDisable = () => unpatch();
```
#### `patcher.after(object, methodName, callback)`
Runs after the target method completes. Receives `(args, result)`. You can return a modified value:
```javascript
const unpatch = BetterCurseForge.patcher.after(targetObj, 'getItemTitle', (args, result) => {
return `⚡ ${result}`;
});
plugin.onDisable = () => unpatch();
```
#### `patcher.instead(object, methodName, callback)`
Completely supersedes the target method. Receives `(args, originalMethod)`:
```javascript
const unpatch = BetterCurseForge.patcher.instead(targetObj, 'fetchData', (args, originalFn) => {
console.log('Intercepting fetchData call...');
return originalFn(...args);
});
plugin.onDisable = () => unpatch();
```
---
### 4. `BetterCurseForge.on(event, callback)` / `.off()` / `.emit()`
Application-wide event emitter:
```javascript
const onReady = () => console.log('CurseForge UI is fully loaded!');
BetterCurseForge.on('ready', onReady);
plugin.onDisable = () => {
BetterCurseForge.off('ready', onReady);
};
```
---
## Complete Example: Compact Mode Plugin
This plugin adds a high-density layout mode to CurseForge.
::: code-group
```json [manifest.json]
{
"id": "compact-mode",
"name": "Compact Mode",
"version": "1.0.0",
"description": "Reduces margins and padding for high-density modpack browsing.",
"author": "BetterCurseForge Team",
"main": "index.js",
"styles": "styles.css"
}
```
```javascript [index.js]
// Add compact layout class to the document root
document.documentElement.classList.add('bcf-compact-layout');
console.log('[Compact Mode] Activated');
// Clean up when toggled off
plugin.onDisable = () => {
document.documentElement.classList.remove('bcf-compact-layout');
console.log('[Compact Mode] Deactivated');
};
```
```css [styles.css]
/* Automatically injected when plugin is enabled */
.bcf-compact-layout .tile-view-item {
width: 150px !important;
height: 240px !important;
}
.bcf-compact-layout .action-button-container {
height: 48px !important;
margin: 6px 0 !important;
}
.bcf-compact-layout .details-title {
font-size: 13px !important;
}
```
:::

248
docs/guide/themes.md Normal file
View file

@ -0,0 +1,248 @@
# Custom Themes & Theming Guide
BetterCurseForge features a native, live-reloading theming engine. Themes allow you to completely restyle the CurseForge Launcher using standard CSS and theme manifests, fully integrated into the official user interface.
---
## In-App Theme Selector
BetterCurseForge embeds client themes directly into CurseForge's native **Settings -> Themes** menu.
### How to Access Client Themes
1. Open CurseForge Launcher.
2. Click the **Settings** gear icon in the bottom-left corner.
3. Select **Themes** from the settings sidebar.
4. Scroll down past the *Classic*, *Colorblind*, and *Premium* categories to find the **Client Themes** section:
![Settings Themes View](/logo.svg){width=48 style="display:block;margin:1rem auto;"}
### Native Features in Settings -> Themes
- **Live Hover Preview**: Hovering your cursor over any theme card immediately previews the theme across the entire launcher in real time. Move your cursor away to revert, or click to apply.
- **Mutual Exclusivity**: Selecting a client theme seamlessly deselects native skins, and vice versa. Only one theme is active at a time.
- **"Open Folder" Button**: Directly opens your themes folder in Windows Explorer so you can drop in new stylesheets.
- **"Refresh" Button**: Reloads all installed custom themes from disk instantly without needing to restart CurseForge.
- **Native Premium Themes Unlocked**: All official CurseForge Premium themes (*Pixelforge*, *Lifeforge*, *Fantasyforge*, and *Spookyforge*) are permanently unlocked and free to use!
---
## Theme Folder Location
Themes are stored in your user application data directory:
```text
%APPDATA%\CurseForge\BetterCurseForge\themes\
```
Full path:
```text
C:\Users\<YourUsername>\AppData\Roaming\CurseForge\BetterCurseForge\themes\
```
You can open this folder anytime by clicking the **Open Folder** button in **Settings -> Themes**.
---
## Theme File Structure
BetterCurseForge supports two convenient formats:
### 1. Single-File Theme (Recommended)
Place a `.css` file in the `themes/` directory, with an optional accompanying `.json` manifest:
```text
themes/
├── purple-night.css
└── purple-night.json (optional metadata & preview colors)
```
### 2. Folder-Based Theme
Create a folder inside `themes/` with a `theme.css` and `theme.json`:
```text
themes/
└── emerald-dark/
├── theme.json
├── theme.css
└── background.webp (optional custom assets)
```
---
## Theme Manifest Format (`theme.json`)
A `.json` manifest provides metadata and defines the miniature preview card colors shown in the **Settings -> Themes** menu:
```json
{
"name": "Emerald Dark",
"version": "1.0.0",
"description": "Deep obsidian tones with vivid emerald green accents.",
"author": "ModderDev",
"primaryColor": "#10b981",
"titleColor": "#064e3b",
"bodyColor": "#022c22",
"backgroundColor": "#051c14"
}
```
### Manifest Fields
| Field | Type | Description |
| :--- | :--- | :--- |
| `name` | `string` | Display name shown on the theme card and in settings. |
| `version` | `string` | Semantic version string (e.g. `1.0.0`). |
| `description` | `string` | Short tooltip description displayed when hovering the title. |
| `author` | `string` | Author name or handle. |
| `primaryColor` | `string` | Hex color for the primary square icon in the thumbnail card. |
| `titleColor` | `string` | Hex color for the top bar in the thumbnail card. |
| `bodyColor` | `string` | Hex color for the lower text bar in the thumbnail card. |
| `backgroundColor`| `string` | Hex background color for the thumbnail card itself. |
---
## CurseForge CSS Variables Reference
CurseForge is styled using CSS Custom Properties (variables). Overriding these variables allows you to theme 95% of the application cleanly without touching individual class names.
### Brand & Accents
| Variable | Description |
| :--- | :--- |
| `--surface-brand-brand` | Primary brand accent (active tabs, primary buttons, indicators). |
| `--surface-brand-brand-hover` | Hover state for brand-colored elements. |
| `--color-primary` | Main accent color. |
| `--color-accent` | Secondary highlight color. |
| `--selected-item-border` | Border style for selected items and active inputs. |
### Surfaces & Backgrounds
| Variable | Description |
| :--- | :--- |
| `--color-background` | Main application background (content views, modpack library). |
| `--surface-base` | Darkest background layer (scroll tracks, container bases). |
| `--surface-primary` | Elevated primary surface (sidebars, sub-panels). |
| `--surface-secondary` | Secondary surface layer (navigation menus, dialog panels). |
| `--surface-tertiery` | Tertiary surface layer (mod cards, list row containers). |
| `--surface-on-surface` | Subtle translucent overlay for buttons (`rgba(...)`). |
| `--surface-on-surface-hover` | Translucent hover overlay for buttons. |
### Modpack & Library Cards
| Variable | Description |
| :--- | :--- |
| `--surface-card` | Modpack / project card base background. |
| `--surface-card-hover` | Background color when hovering over modpack cards. |
| `--color-surface-card-active` | Active or clicked state for card items. |
### Typography & Text
| Variable | Description |
| :--- | :--- |
| `--text-pure` | Pure white text (`#ffffff`). |
| `--text-primary` | Primary readable text color. |
| `--color-text-title` | Headings, modpack titles, section names. |
| `--color-text-body` | Standard body and description text. |
| `--color-text-secondary` | Subtitles, authors, version numbers. |
### Title Bar & Navigation
| Variable | Description |
| :--- | :--- |
| `--title-bar-background` | Background color or gradient for the top window title bar. |
| `--btn-window-action` | Minimize, maximize, and close button icons. |
| `--color-scrollbar-thumb` | Scrollbar thumb handle color. |
---
## Example Theme: Emerald Night
Here is a complete, working theme stylesheet you can save as `emerald-night.css`:
::: code-group
```css [emerald-night.css]
/* ==========================================================================
BetterCurseForge Theme: Emerald Night
========================================================================== */
:root,
.cf-app,
.base-skin,
.dark-skin,
.cf-classic {
/* ── Accent & Brand ── */
--surface-brand-brand: #10b981 !important;
--surface-brand-brand-hover: #34d399 !important;
--color-primary: #10b981 !important;
--color-primary-hover: #34d399 !important;
--color-primary-active: #059669 !important;
--color-accent: #6ee7b7 !important;
--selected-item-border: 2px solid #10b981 !important;
/* ── Backgrounds & Surfaces ── */
--color-background: #06140f !important;
--surface-base: #030b08 !important;
--surface-primary: #091c16 !important;
--surface-secondary: #0d271f !important;
--surface-tertiery: #13352b !important;
--surface-tertiery-hover: #194639 !important;
/* ── Modpack & Library Cards ── */
--surface-card: #0d271f !important;
--surface-card-hover: #14382d !important;
/* ── Typography & Text ── */
--text-pure: #ffffff !important;
--text-primary: #f0fdf4 !important;
--color-text-title: #ffffff !important;
--color-text-body: #d1fae5 !important;
--color-text-secondary: #6ee7b7 !important;
/* ── Title Bar ── */
--title-bar-background: #091c16 !important;
--btn-window-action: #6ee7b7 !important;
/* ── Scrollbars ── */
--color-scrollbar-thumb: #13352b !important;
}
/* Primary Action Buttons */
.btn-primary:not(.btn-secondary),
button[class*="primary"]:not([class*="secondary"]) {
background: #10b981 !important;
color: #030b08 !important;
font-weight: 600 !important;
}
.btn-primary:not(.btn-secondary):hover,
button[class*="primary"]:not([class*="secondary"]):hover {
background: #34d399 !important;
}
```
```json [emerald-night.json]
{
"name": "Emerald Night",
"version": "1.0.0",
"description": "Deep emerald dark theme for CurseForge Launcher.",
"author": "BetterCurseForge",
"primaryColor": "#10b981",
"titleColor": "#0d271f",
"bodyColor": "#06140f",
"backgroundColor": "#091c16"
}
```
:::
---
## Tips for Inspecting Elements
Because CurseForge runs on Electron and React, you can inspect any UI element using Chromium DevTools:
1. Press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> in CurseForge to open DevTools.
2. Press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>C</kbd> to activate the element inspector.
3. Click on any button, banner, header, or list item to reveal its computed styles and classes in the **Elements** panel.
4. Test CSS changes live in the **Styles** tab before adding them to your theme file.
5. In **Settings -> Themes**, click **Refresh** to instantly apply your modified theme stylesheet!

67
docs/index.md Normal file
View file

@ -0,0 +1,67 @@
---
layout: home
hero:
name: "BetterCurseForge"
text: "Client mod for CurseForge Launcher"
tagline: "Ad-free experience, custom themes, unlocked premium skins, and plugins."
actions:
- theme: brand
text: Get Started
link: /guide/installation
- theme: alt
text: View on GitHub
link: https://github.com/BetterCurseForge/BetterCurseForge
features:
- icon: 🚫
title: Complete Ad Removal
details: Multi-layer elimination (React component neutralization, zero-ad CSS rules, and network blocking) removes all video ads, banners, and empty containers.
- icon: 🎨
title: Client Themes & Free Skins
details: Integrated directly into Settings -> Themes with live hover previews, theme folder shortcuts, and all native CurseForge Premium themes unlocked for free.
- icon: 🔄
title: Auto-Repatch
details: Survives launcher updates effortlessly. The automated background task detects CurseForge updates and silently re-patches the client.
- 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.
- icon: 🧩
title: Plugin System
details: Extend launcher capabilities with lightweight JavaScript plugins using our runtime monkey-patcher and persistent settings API.
- icon: 🛡️
title: Safe & 100% Reversible
details: Automatically creates a clean vanilla backup (`app.asar.vanilla.bak`). You can restore stock CurseForge at any time with a single click.
---
<div class="quickstart-section" style="margin-top: 3rem; text-align: center;">
## Quick Start
Download `BetterCurseForge.exe` and patch in one click:
```bash
# Launch the desktop GUI:
BetterCurseForge.exe
# Or patch directly from your terminal:
BetterCurseForge.exe patch
```
</div>
<div class="features-overview" style="margin-top: 2rem;">
## Why BetterCurseForge?
CurseForge is the premier platform for Minecraft modpacks, World of Warcraft addons, and game mods, but the official desktop app is burdened with intrusive ad containers, locked premium themes, and heavy telemetry.
**BetterCurseForge** restores a clean, fast, and customizable experience:
- **Seamless Native UI**: Client themes appear natively under **Settings -> Themes** alongside official themes, complete with real-time live hover previews.
- **Unlocked Premium Themes**: Access all native CurseForge Premium themes (*Pixelforge*, *Lifeforge*, *Fantasyforge*, *Spookyforge*) without a subscription.
- **Zero-Ad Layouts**: Ad columns and sidebars are eliminated from the layout, letting modpack grids expand to fill your entire display.
- **Automated Update Resistance**: Never worry about CurseForge updates breaking your setup—**Auto-Repatch** automatically detects updates and re-applies the patch in seconds.
- **One-Click Reversible**: Easily unpatch anytime via the GUI or `BetterCurseForge.exe unpatch` to restore vanilla CurseForge.
</div>

2519
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

13
docs/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "bettercurseforge-docs",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.0"
}
}

20
docs/public/logo.svg Normal file
View file

@ -0,0 +1,20 @@
<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>

After

Width:  |  Height:  |  Size: 1 KiB

979
patcher/patch_core.py Normal file
View file

@ -0,0 +1,979 @@
"""
BetterCurseForge — Core Patching Logic
=======================================
Direct ASAR modification + OWEASARSIG header hash synchronization.
Works on all versions of CurseForge (ow-electron).
"""
import os
import sys
import json
import struct
import shutil
import hashlib
import winreg
import subprocess
from pathlib import Path
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
WATCHER_VALUE_NAME = "BetterCurseForgeWatcher"
WATCHER_MUTEX_NAME = "BetterCurseForgeWatcherMutex"
PERMANENT_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "BetterCurseForge"
PERMANENT_EXE = PERMANENT_DIR / "BetterCurseForge.exe"
PERMANENT_BCF = PERMANENT_DIR / "bettercf"
PERMANENT_VENDOR = PERMANENT_DIR / "vendor"
def get_curseforge_dir() -> Path | None:
# 1. Registry lookup
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Uninstall",
)
i = 0
while True:
try:
subkey_name = winreg.EnumKey(key, i)
i += 1
try:
subkey = winreg.OpenKey(key, subkey_name)
display_name, _ = winreg.QueryValueEx(subkey, "DisplayName")
if "CurseForge" in display_name:
uninstall_str, _ = winreg.QueryValueEx(subkey, "UninstallString")
path_str = uninstall_str.strip('"').split('" ')[0].strip('"')
install_dir = Path(path_str).parent
if install_dir.exists() and (install_dir / "CurseForge.exe").exists():
winreg.CloseKey(subkey)
winreg.CloseKey(key)
return install_dir
winreg.CloseKey(subkey)
except (FileNotFoundError, OSError):
pass
except OSError:
break
winreg.CloseKey(key)
except OSError:
pass
# 2. LocalAppData fallback
appdata = os.environ.get("LOCALAPPDATA", "")
for p in [
Path(appdata) / "Programs" / "CurseForge Windows",
Path(appdata) / "CurseForge",
]:
if p.exists() and (p / "CurseForge.exe").exists():
return p
return None
def kill_curseforge():
try:
subprocess.run(["taskkill", "/f", "/im", "CurseForge.exe"], capture_output=True, timeout=10)
except Exception:
pass
def calc_asar_header_hash(asar_path: Path) -> str:
with open(asar_path, "rb") as f:
f.seek(12)
u4 = struct.unpack("<I", f.read(4))[0]
header_bytes = f.read(u4)
return hashlib.sha256(header_bytes).hexdigest()
def read_exe_hash(exe_path: Path) -> str | None:
try:
with open(exe_path, "rb") as f:
data = f.read()
sig = "OWEASARSIG".encode("utf-16le")
idx = data.rfind(sig)
if idx == -1:
idx = data.rfind(b"OWEASARSIG")
if idx == -1:
return None
val_idx = data.find(b'"value":"', idx)
if val_idx == -1:
return None
hash_start = val_idx + 9
return data[hash_start:hash_start+64].decode("ascii", errors="replace")
except Exception:
return None
def update_exe_hash(exe_path: Path, new_hash: str) -> bool:
with open(exe_path, "rb") as f:
data = bytearray(f.read())
sig = "OWEASARSIG".encode("utf-16le")
idx = data.rfind(sig)
if idx == -1:
idx = data.rfind(b"OWEASARSIG")
if idx == -1:
raise ValueError("OWEASARSIG signature block not found in CurseForge.exe")
val_idx = data.find(b'"value":"', idx)
if val_idx == -1:
raise ValueError('"value":" field not found in OWEASARSIG resource')
hash_start = val_idx + 9
old_hash = data[hash_start:hash_start+64].decode("ascii", errors="replace")
data[hash_start:hash_start+64] = new_hash.encode("ascii")
with open(exe_path, "wb") as f:
f.write(data)
return True
def get_user_data_dir() -> Path:
appdata = Path(os.environ.get("APPDATA", ""))
bcf_dir = appdata / "CurseForge" / "BetterCurseForge"
(bcf_dir / "themes").mkdir(parents=True, exist_ok=True)
(bcf_dir / "plugins").mkdir(parents=True, exist_ok=True)
return bcf_dir
def ensure_sample_content(bcf_src: Path):
user_dir = get_user_data_dir()
themes_src = bcf_src / "themes"
plugins_src = bcf_src / "plugins"
if themes_src.exists():
for item in themes_src.iterdir():
dest = user_dir / "themes" / item.name
if item.is_file():
if not dest.exists() or item.name.startswith("purple-night"):
shutil.copy2(item, dest)
elif item.is_dir() and not dest.exists():
shutil.copytree(item, dest)
if plugins_src.exists():
for item in plugins_src.iterdir():
dest = user_dir / "plugins" / item.name
if not dest.exists():
if item.is_file():
shutil.copy2(item, dest)
elif item.is_dir():
shutil.copytree(item, dest)
def find_bettercf_source() -> Path | None:
candidates = [
Path(getattr(sys, "_MEIPASS", "")) / "bettercf" if hasattr(sys, "_MEIPASS") else None,
Path(sys.executable).parent / "bettercf" if getattr(sys, "frozen", False) else None,
PERMANENT_BCF,
Path(__file__).parent / "bettercf",
Path(__file__).parent.parent / "bettercf",
Path.cwd() / "bettercf",
]
for c in candidates:
if c and c.exists() and c.is_dir():
return c
return None
def find_vendor_dir() -> Path | None:
candidates = [
Path(getattr(sys, "_MEIPASS", "")) / "vendor" if hasattr(sys, "_MEIPASS") else None,
Path(sys.executable).parent / "vendor" if getattr(sys, "frozen", False) else None,
PERMANENT_VENDOR,
Path(__file__).parent / "vendor",
Path(__file__).parent.parent / "patcher" / "vendor",
Path.cwd() / "patcher" / "vendor",
Path.cwd() / "vendor",
]
for c in candidates:
if c and c.exists() and (c / "asar" / "node_modules" / "@electron" / "asar" / "bin" / "asar.mjs").exists():
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
CurseForge's internal Node v24 runtime or system node.
Falls back to npx only if offline runners fail.
"""
vendor_dir = find_vendor_dir()
if vendor_dir:
asar_mjs = vendor_dir / "asar" / "node_modules" / "@electron" / "asar" / "bin" / "asar.mjs"
node_modules = vendor_dir / "asar" / "node_modules"
runners = []
# Runner 1: CurseForge.exe itself (built-in Node v24, 100% offline & compatible)
if cf_dir and (cf_dir / "CurseForge.exe").exists():
runners.append((str(cf_dir / "CurseForge.exe"), True))
# Runner 2: System Node.js
sys_node = shutil.which("node")
if sys_node:
runners.append((sys_node, False))
# Runner 3: Vanilla backup of CurseForge.exe
if cf_dir and (cf_dir / "CurseForge.exe.vanilla").exists():
runners.append((str(cf_dir / "CurseForge.exe.vanilla"), True))
for runner_bin, is_electron in runners:
try:
env = os.environ.copy()
if is_electron:
env["ELECTRON_RUN_AS_NODE"] = "1"
env["NODE_PATH"] = str(node_modules)
cmd = [runner_bin, str(asar_mjs)] + args
creation_flags = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
r = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
stdin=subprocess.DEVNULL,
creationflags=creation_flags,
timeout=45
)
if r.returncode == 0:
return (True, r.stdout.strip())
else:
err_msg = r.stderr.strip() or r.stdout.strip()
except Exception as e:
err_msg = str(e)
continue
# Fallback to npx if vendor runner was unavailable
try:
cmd_npx = ["npx", "--yes", "@electron/asar"] + args
r = subprocess.run(
cmd_npx,
capture_output=True,
text=True,
shell=True,
stdin=subprocess.DEVNULL,
timeout=60
)
if r.returncode == 0:
return (True, r.stdout.strip())
return (False, r.stderr.strip() or r.stdout.strip())
except Exception as e:
return (False, str(e))
def is_asar_patched(asar_path: Path) -> bool:
if not asar_path.exists():
return False
try:
with open(asar_path, "rb") as f:
f.seek(12)
u4 = struct.unpack("<I", f.read(4))[0]
header = f.read(u4).decode("utf-8", errors="replace")
return "bettercf" in header
except Exception:
return False
def is_curseforge_patched(cf_dir: Path) -> bool:
asar_path = cf_dir / "resources" / "app.asar"
return is_asar_patched(asar_path)
def patch_curseforge(log_callback=print) -> bool:
cf_dir = get_curseforge_dir()
if not cf_dir:
log_callback("CurseForge installation directory not found!")
return False
resources_dir = cf_dir / "resources"
asar_path = resources_dir / "app.asar"
exe_path = cf_dir / "CurseForge.exe"
if not asar_path.exists() or not exe_path.exists():
log_callback("CurseForge resources/app.asar or CurseForge.exe missing!")
return False
asar_bak = resources_dir / "app.asar.vanilla"
exe_bak = cf_dir / "CurseForge.exe.vanilla"
log_callback(f"Found CurseForge at: {cf_dir}")
kill_curseforge()
# If CurseForge just updated (clean vanilla files from Overwolf), refresh vanilla backups
try:
current_exe_sig = read_exe_hash(exe_path)
current_asar_hash = calc_asar_header_hash(asar_path)
if current_exe_sig == current_asar_hash and not is_curseforge_patched(cf_dir):
# Clean Overwolf update detected! Refresh vanilla backups to match new version
shutil.copy2(asar_path, asar_bak)
shutil.copy2(exe_path, exe_bak)
log_callback(f"[+] Updated vanilla backup to latest CurseForge version")
except Exception:
pass
if not asar_bak.exists() or is_asar_patched(asar_bak):
if not is_asar_patched(asar_path):
log_callback("Creating vanilla backup of app.asar...")
shutil.copy2(asar_path, asar_bak)
if not exe_bak.exists():
log_callback("Creating vanilla backup of CurseForge.exe...")
shutil.copy2(exe_path, exe_bak)
# Clean old unused resources/app folder if present
old_app = resources_dir / "app"
if old_app.exists():
try:
shutil.rmtree(old_app)
except Exception:
pass
# Extract asar
temp_extract = resources_dir / "bettercf_extract_temp"
if temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
# Always extract asar_path directly (so @electron/asar finds app.asar.unpacked)
source_asar = asar_path
log_callback(f"Extracting {'vanilla backup' if source_asar == asar_bak else 'app.asar'}...")
ok, err = run_asar_tool(["extract", str(source_asar), str(temp_extract)], cf_dir=cf_dir)
if not ok:
log_callback(f"Failed to extract asar: {err}")
return False
try:
# 1. Zero-Ad & Themes Patch in desktop.js
desktop_js = temp_extract / "dist" / "desktop" / "desktop.js"
if desktop_js.exists():
content = desktop_js.read_text(encoding="utf-8")
# Clean up any legacy or duplicate __orig_eV replacements
while "let __orig_eV=e=>null;" in content:
content = content.replace("let __orig_eV=e=>null;", "")
if "eV=e=>null;let __orig_eV=e=>{" in content:
content = content.replace("eV=e=>null;let __orig_eV=e=>{", "eV=e=>{")
# Idempotently disable React ad component (eV -> return null)
if "/*BCF_AD_OFF*/" not in content and "eV=e=>{let" in content:
content = content.replace("eV=e=>{let", "/*BCF_AD_OFF*/eV=e=>{return null;let", 1)
log_callback("[+] Disabled React ad component (eV -> return null)")
if "showSwapAdsButton:T" in content:
content = content.replace("showSwapAdsButton:T", "showSwapAdsButton:!1")
log_callback("[+] Disabled swap ads button")
if "R=e=>!!e.forceUnlock||!e.isPremium||!!M" in content:
content = content.replace("R=e=>!!e.forceUnlock||!e.isPremium||!!M", "R=e=>!0")
log_callback("[+] Unlocked all native premium themes (Settings route)")
if "P=e=>!!e.forceUnlock||!e.isPremium||f" in content:
content = content.replace("P=e=>!!e.forceUnlock||!e.isPremium||f", "P=e=>!0")
log_callback("[+] Unlocked all native premium themes (Popup route)")
if "return!r?.forceUnlock&&r?.isPremium&&!t?u.Sx.Classic:n" in content:
content = content.replace("return!r?.forceUnlock&&r?.isPremium&&!t?u.Sx.Classic:n", "return n")
log_callback("[+] Unlocked premium theme state validation")
if "function iy(e){return e.subscription.plan?.active}" in content:
content = content.replace("function iy(e){return e.subscription.plan?.active}", "function iy(e){return !0}")
log_callback("[+] Unlocked subscription plan status for themes")
desktop_js.write_text(content, encoding="utf-8")
# 2. Zero-Ad & Settings Themes CSS in desktop.css
desktop_css = temp_extract / "dist" / "desktop" / "desktop.css"
if desktop_css.exists():
css_rules = """
/* ═══ BetterCurseForge Zero-Ad Rules ═══ */
.curseforge-ad,
.ad-inner,
.overwolf-ad,
.swap-ads-location,
owadview,
[class*="-skin-aside"],
.promo-banner,
.subscribe-link,
.subscribe-link-small-resolution,
.subscribe-link-large-resolution,
[data-ad],
[data-ad-slot] {
display: none !important;
width: 0 !important;
height: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
position: absolute !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* ═══ BetterCurseForge Settings Themes UI ═══ */
#bcf-custom-themes-section {
margin-top: 12px !important;
padding: 0 !important;
}
.bcf-themes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.bcf-heading {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 24px;
color: var(--color-text-title, currentColor);
}
.bcf-heading > span:first-child {
display: inline-flex;
align-items: center;
}
.bcf-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: #a78bfa;
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 3px 8px;
border-radius: 10px;
letter-spacing: 0.3px;
}
.bcf-actions {
display: flex;
gap: 8px;
align-items: center;
}
.bcf-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
height: 28px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--color-text-primary, currentColor);
background: var(--surface-on-surface, rgba(255, 255, 255, 0.08));
border: 1px solid var(--border-tertiery, rgba(255, 255, 255, 0.15));
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.bcf-btn svg {
display: block;
flex-shrink: 0;
width: 13px;
height: 13px;
}
.bcf-btn:hover {
background: var(--surface-on-surface-hover, rgba(255, 255, 255, 0.16));
border-color: rgba(255, 255, 255, 0.3);
color: var(--color-text-title-hover, #ffffff);
}
.bcf-btn:active {
transform: scale(0.97);
}
.bcf-theme-thumb {
border-radius: 0;
}
.bcf-theme-thumb .thumb-icon svg {
display: block;
}
.bcf-theme-thumb.is-active .sprite-icon {
opacity: 1 !important;
color: #ffffff !important;
}
/* Mutual exclusivity: hide checkmark on native theme cards when a client theme is active */
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .sprite-icon {
opacity: 0 !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active .thumb-icon {
background-color: transparent !important;
}
html.bcf-custom-theme-active .settings-themes-view .theme-thumb:not(.bcf-theme-thumb).is-active {
outline: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* Neutralize native skin styles and title bar */
html.bcf-custom-theme-active .title-bar,
html.bcf-custom-theme-active [class*="title-bar"],
html.bcf-custom-theme-active [class*="TitleBar"] {
background: #0d0818 !important;
--title-bar-background: #0d0818 !important;
}
html.bcf-custom-theme-active .pixelforge-skin,
html.bcf-custom-theme-active .lifeforge-skin,
html.bcf-custom-theme-active .fantasyforge-skin,
html.bcf-custom-theme-active .halloween-skin,
html.bcf-custom-theme-active .cf-light,
html.bcf-custom-theme-active .cf-light-colorblind {
--title-bar-background: #0d0818 !important;
--surface-brand-brand: #8b5cf6 !important;
--surface-brand-brand-hover: #9d74f7 !important;
--accent-font: var(--body-font, "Lato", sans-serif) !important;
background-color: transparent !important;
}
html.bcf-custom-theme-active .pixelforge-skin :not(.highlighted-project-container) .card:after {
display: none !important;
}
"""
css_text = desktop_css.read_text(encoding="utf-8")
if "/* ═══ BetterCurseForge Zero-Ad Rules ═══ */" not in css_text:
desktop_css.write_text(css_text + "\n" + css_rules, encoding="utf-8")
log_callback("[+] Injected zero-ad and theme CSS rules into desktop.css")
# 3. Copy BetterCF runtime into asar
bcf_src = find_bettercf_source()
if bcf_src:
ensure_sample_content(bcf_src)
bcf_dest = temp_extract / "dist" / "bettercf"
if bcf_dest.exists():
shutil.rmtree(bcf_dest)
shutil.copytree(bcf_src, bcf_dest)
log_callback("[+] Bundled BetterCurseForge runtime into asar")
# 4. Inject main process hooks in background.js
bg_js = temp_extract / "dist" / "background" / "background.js"
if bg_js.exists():
bg_content = bg_js.read_text(encoding="utf-8")
if "bettercf/core/main-process.js" not in bg_content:
bg_inject = "import '../bettercf/core/main-process.js';\n"
bg_js.write_text(bg_inject + bg_content, encoding="utf-8")
log_callback("[+] Injected main-process hooks into background.js")
# 5. Inject renderer script into desktop.html
desktop_html = temp_extract / "dist" / "desktop" / "desktop.html"
if desktop_html.exists():
html_content = desktop_html.read_text(encoding="utf-8")
if "bettercf/core/renderer.js" not in html_content:
bcf_script = '<script src="../bettercf/core/renderer.js"></script>'
html_content = html_content.replace("</head>", f"{bcf_script}</head>")
desktop_html.write_text(html_content, encoding="utf-8")
log_callback("[+] Injected renderer script into desktop.html")
# 6. Inject preload hook in preload.js
preload_js = temp_extract / "dist" / "preload" / "preload.js"
if preload_js.exists():
preload_content = preload_js.read_text(encoding="utf-8")
if "__BCF_IPC__" not in preload_content:
preload_hook = """
// ── BetterCurseForge Preload Hook ──
try {
const { ipcRenderer } = require('electron');
window.__BCF_IPC__ = {
invoke: (...args) => ipcRenderer.invoke(...args),
};
} catch (e) {}
"""
preload_js.write_text(preload_content + "\n" + preload_hook, encoding="utf-8")
log_callback("[+] Injected renderer loader into preload.js")
# 6. Repack ASAR
log_callback("Repacking app.asar...")
ok, err = run_asar_tool(["pack", str(temp_extract), str(asar_path)], cf_dir=cf_dir)
if not ok:
log_callback(f"Failed to pack asar: {err}")
return False
# 7. Compute new hash and update CurseForge.exe
new_hash = calc_asar_header_hash(asar_path)
log_callback(f"New ASAR header hash: {new_hash}")
# Always restore clean vanilla exe before patching hash
shutil.copy2(exe_bak, exe_path)
update_exe_hash(exe_path, new_hash)
log_callback("[+] Updated CurseForge.exe integrity signature")
finally:
if temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
try:
ensure_permanent_installation()
set_autostart_watcher(True)
start_background_watcher()
log_callback("[+] Auto-repatch watcher active and persistent across updates.")
except Exception as e:
log_callback(f"[!] Note: Could not auto-start watcher: {e}")
log_callback("[*] CurseForge successfully patched! Ads removed, themes & plugins enabled.")
return True
def unpatch_curseforge(log_callback=print) -> bool:
cf_dir = get_curseforge_dir()
if not cf_dir:
log_callback("CurseForge not found.")
return False
kill_curseforge()
resources_dir = cf_dir / "resources"
asar_path = resources_dir / "app.asar"
exe_path = cf_dir / "CurseForge.exe"
asar_bak = resources_dir / "app.asar.vanilla"
exe_bak = cf_dir / "CurseForge.exe.vanilla"
restored = False
if asar_bak.exists():
shutil.copy2(asar_bak, asar_path)
log_callback("[+] Restored vanilla app.asar")
restored = True
if exe_bak.exists():
shutil.copy2(exe_bak, exe_path)
log_callback("[+] Restored vanilla CurseForge.exe")
restored = True
old_app = resources_dir / "app"
if old_app.exists():
shutil.rmtree(old_app, ignore_errors=True)
try:
set_autostart_watcher(False)
stop_background_watcher()
log_callback("[-] Auto-repatch watcher deactivated.")
except Exception:
pass
if restored:
log_callback("CurseForge successfully returned to vanilla.")
else:
log_callback("Vanilla backups not found. Reinstall CurseForge to restore completely.")
return restored
# ═══════════════════════════════════════════════════════════════════════════════
# AUTO-REPATCH WATCHER & PERSISTENCE ENGINE
def is_curseforge_running() -> bool:
try:
out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq CurseForge.exe", "/NH"],
text=True, stderr=subprocess.DEVNULL
)
return "CurseForge.exe" in out
except Exception:
return False
def is_updater_running() -> bool:
try:
out = subprocess.check_output(
["tasklist", "/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL
)
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 ensure_permanent_installation() -> Path:
"""
Ensures BetterCurseForge executable, bettercf runtime, and vendor tools are permanently
installed to %LOCALAPPDATA%\\BetterCurseForge so auto-repatch never breaks.
"""
try:
PERMANENT_DIR.mkdir(parents=True, exist_ok=True)
src_bcf = find_bettercf_source()
if src_bcf and src_bcf.exists():
if PERMANENT_BCF.resolve() != src_bcf.resolve():
if PERMANENT_BCF.exists():
shutil.rmtree(PERMANENT_BCF, ignore_errors=True)
shutil.copytree(src_bcf, PERMANENT_BCF)
src_vendor = find_vendor_dir()
if src_vendor and src_vendor.exists():
if PERMANENT_VENDOR.resolve() != src_vendor.resolve():
if PERMANENT_VENDOR.exists():
shutil.rmtree(PERMANENT_VENDOR, ignore_errors=True)
shutil.copytree(src_vendor, PERMANENT_VENDOR)
if getattr(sys, "frozen", False):
curr_exe = Path(sys.executable).resolve()
if curr_exe != PERMANENT_EXE.resolve():
shutil.copy2(curr_exe, PERMANENT_EXE)
else:
dist_exe = Path(__file__).parent.parent / "dist" / "BetterCurseForge.exe"
if dist_exe.exists():
shutil.copy2(dist_exe, PERMANENT_EXE)
except Exception:
pass
return PERMANENT_EXE if PERMANENT_EXE.exists() else Path(sys.executable)
def is_autostart_watcher_enabled() -> bool:
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_READ)
try:
val, _ = winreg.QueryValueEx(key, WATCHER_VALUE_NAME)
winreg.CloseKey(key)
return bool(val)
except FileNotFoundError:
winreg.CloseKey(key)
return False
except OSError:
return False
def set_autostart_watcher(enable: bool) -> bool:
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE)
if enable:
perm_exe = ensure_permanent_installation()
if perm_exe.suffix.lower() == ".exe":
cmd = f'"{perm_exe.resolve()}" --watch'
else:
python_exe = Path(sys.executable).parent / "pythonw.exe"
if not python_exe.exists():
python_exe = Path(sys.executable)
cmd = f'"{python_exe.resolve()}" "{Path(__file__).parent.resolve() / "patcher_gui.py"}" --watch'
winreg.SetValueEx(key, WATCHER_VALUE_NAME, 0, winreg.REG_SZ, cmd)
try:
subprocess.run(
["schtasks", "/Create", "/TN", "BetterCurseForgeWatcher",
"/TR", cmd, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"],
capture_output=True, timeout=5
)
except Exception:
pass
else:
try:
winreg.DeleteValue(key, WATCHER_VALUE_NAME)
except FileNotFoundError:
pass
try:
subprocess.run(["schtasks", "/Delete", "/TN", "BetterCurseForgeWatcher", "/F"],
capture_output=True, timeout=5)
except Exception:
pass
winreg.CloseKey(key)
return True
except OSError:
return False
def is_watcher_running() -> bool:
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, WATCHER_MUTEX_NAME)
if h:
kernel32.CloseHandle(h)
return True
return False
def start_background_watcher():
if is_watcher_running():
return
perm_exe = ensure_permanent_installation()
if perm_exe.exists() and perm_exe.suffix.lower() == ".exe":
cmd_str = f'"{perm_exe.resolve()}" --watch'
elif getattr(sys, "frozen", False):
cmd_str = f'"{Path(sys.executable).resolve()}" --watch'
else:
python_exe = Path(sys.executable).parent / "pythonw.exe"
if not python_exe.exists():
python_exe = Path(sys.executable)
cmd_str = f'"{python_exe.resolve()}" "{Path(__file__).parent.resolve() / "patcher_gui.py"}" --watch'
try:
ps_cmd = f"Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{{CommandLine = '{cmd_str}'}}"
subprocess.run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd],
capture_output=True, timeout=5)
except Exception:
try:
creation_flags = 0x08000000 | 0x00000200
subprocess.Popen(f'start "" {cmd_str}', shell=True, creationflags=creation_flags)
except Exception:
pass
def stop_background_watcher():
try:
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--watch*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, timeout=10
)
except Exception:
pass
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.
"""
import time
start = time.time()
asar_path = cf_dir / "resources" / "app.asar"
last_size = -1
stable_checks = 0
while time.time() - start < timeout:
time.sleep(1.5)
if is_updater_running():
stable_checks = 0
if log_fn:
log_fn("CurseForge installer is currently running... waiting for completion.")
continue
if asar_path.exists():
try:
asar_size = asar_path.stat().st_size
if asar_size > 5_000_000:
if asar_size == last_size:
stable_checks += 1
if stable_checks >= 2:
try:
with open(asar_path, "rb") as f:
magic = f.read(4)
if len(magic) == 4:
if log_fn:
log_fn(f"Installer finished. app.asar settled ({asar_size // 1048576}MB).")
return True
except (PermissionError, OSError):
stable_checks = 0
else:
stable_checks = 0
last_size = asar_size
if log_fn:
log_fn(f"Waiting for app.asar to settle... ({asar_size // 1048576}MB)")
except (OSError, PermissionError):
stable_checks = 0
continue
return False
def wait_for_update_and_repatch(pid_to_wait: int | None = None, log_fn=None) -> bool:
"""
Invoked when CurseForge is quitting to apply an update.
Waits for the old CurseForge instance to exit, waits for the installer to finish,
and immediately repatches before/as CurseForge restarts.
"""
import time
if log_fn is None:
log_dir = get_user_data_dir()
watch_log = log_dir / "watcher.log"
def log_fn(msg):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
try:
with open(watch_log, "a", encoding="utf-8") as f:
f.write(f"[{ts}] [OnUpdateHook] {msg}\n")
except Exception:
pass
log_fn(f"Wait-for-update triggered (PID={pid_to_wait})")
cf_dir = get_curseforge_dir()
if not cf_dir:
log_fn("CurseForge directory not found.")
return False
if pid_to_wait:
log_fn(f"Waiting for PID {pid_to_wait} to terminate...")
for _ in range(30):
try:
out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid_to_wait}", "/NH"],
text=True, stderr=subprocess.DEVNULL
)
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)
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)
if success:
break
log_fn(f"Patch attempt {attempt} failed, retrying in 2 seconds...")
time.sleep(2)
if success:
log_fn("★ CurseForge successfully re-patched after update!")
log_fn("Launching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
return True
else:
log_fn("Failed to re-patch CurseForge after update.")
return False
def run_watcher_loop():
"""
Resilient Win32 filesystem watcher with kernel event signaling + 3s heartbeat fallback.
0% CPU consumption during idle.
Instantly and reliably re-patches CurseForge when updated.
"""
import ctypes
import time
from datetime import datetime
log_dir = get_user_data_dir()
watch_log = log_dir / "watcher.log"
def wlog(msg):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with open(watch_log, "a", encoding="utf-8") as f:
f.write(f"[{ts}] {msg}\n")
except Exception:
pass
kernel32 = ctypes.windll.kernel32
kernel32.CreateMutexW.restype = ctypes.c_void_p
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
mutex = kernel32.CreateMutexW(None, True, WATCHER_MUTEX_NAME)
if kernel32.GetLastError() == 183: # ERROR_ALREADY_EXISTS
wlog("Watcher already running (mutex exists). Exiting duplicate.")
return
wlog("Watcher started.")
cf_dir = get_curseforge_dir()
if not cf_dir:
wlog("CurseForge directory not found. Watcher exiting.")
return
def check_and_repatch(trigger_reason: str):
if not is_curseforge_patched(cf_dir):
wlog(f"CurseForge unpatched state detected ({trigger_reason}). Waiting for files to settle...")
settled = wait_for_files_settled(cf_dir, timeout=90.0, log_fn=wlog)
if settled:
was_running = is_curseforge_running()
if was_running:
wlog("CurseForge is currently running unpatched. Closing it to apply BetterCurseForge patch...")
kill_curseforge()
time.sleep(1.5)
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:
wlog("Relaunching modded CurseForge...")
subprocess.Popen([str(cf_dir / "CurseForge.exe")])
else:
wlog("Failed to re-patch CurseForge after update.")
else:
wlog("Timed out waiting for installer files to settle.")
check_and_repatch("Startup Check")
try:
while True:
time.sleep(3)
try:
check_and_repatch("Heartbeat Check")
except Exception as e:
wlog(f"Error during check_and_repatch: {e}")
finally:
if mutex:
kernel32.CloseHandle(mutex)
wlog("Watcher stopped.")

63
patcher/patcher.py Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
BetterCurseForge Patcher — CLI
===============================
Command-line interface for patching CurseForge Launcher.
"""
import sys, os
if os.name == "nt":
if sys.stdout is not None:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if sys.stderr is not None:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
try:
from . import patch_core
except ImportError:
import patch_core
VERSION = "1.0.0"
BANNER = r"""
╔══════════════════════════════════════════╗
║ ⬡ BetterCurseForge Patcher ⬡ ║
║ v{ver} ║
╚══════════════════════════════════════════╝
""".format(ver=VERSION)
def main():
print(BANNER)
if len(sys.argv) < 2:
print("Usage: python patcher.py [patch | unpatch | status | watch]")
print("For GUI, run without arguments or run patcher_gui.py")
sys.exit(1)
cmd = sys.argv[1].lower()
if cmd in ("watch", "--watch"):
patch_core.run_watcher_loop()
elif cmd == "--wait-for-update":
pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
patch_core.wait_for_update_and_repatch(pid)
elif cmd == "--ensure-watcher":
if not patch_core.is_watcher_running():
patch_core.start_background_watcher()
elif cmd == "patch":
patch_core.patch_curseforge()
elif cmd == "unpatch":
patch_core.unpatch_curseforge()
elif cmd == "status":
cf = patch_core.get_curseforge_dir()
if not cf:
print("CurseForge installation not found.")
else:
print(f"CurseForge directory: {cf}")
patched = patch_core.is_curseforge_patched(cf)
print(f"Patch status: {'PATCHED' if patched else 'NOT PATCHED'}")
print(f"Auto-repatch watcher: {'ACTIVE' if patch_core.is_watcher_running() else 'INACTIVE'}")
else:
print(f"Unknown command: {cmd}")
sys.exit(1)
if __name__ == "__main__":
main()

427
patcher/patcher_gui.py Normal file
View file

@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""
BetterCurseForge — Graphical Patcher & Management Suite
========================================================
Single executable GUI for patching CurseForge, managing themes/plugins,
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"):
import ctypes
ctypes.windll.kernel32.AttachConsole(-1)
if sys.stdout is not None:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if sys.stderr is not None:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import json
import subprocess
import threading
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
from pathlib import Path
from datetime import datetime
# Import patch core logic
try:
from . import patch_core
except ImportError:
import patch_core
VERSION = "1.0.0"
# ═══════════════════════════════════════════════════════════════════════════════
# COLOR PALETTE (Modern Discord / Vencord Dark Theme)
# ═══════════════════════════════════════════════════════════════════════════════
COLORS = {
"bg": "#121214",
"bg_card": "#18191c",
"bg_elevated": "#202225",
"bg_input": "#0e0f10",
"purple": "#5865f2",
"purple_hover": "#4752c4",
"purple_dark": "#3c45a5",
"purple_glow": "#7983f5",
"green": "#57f287",
"green_dark": "#237e44",
"red": "#ed4245",
"yellow": "#fee75c",
"text": "#f2f3f5",
"text_dim": "#949ba4",
"text_muted": "#5c6067",
"border": "#2b2d31",
"border_focus": "#5865f2",
}
# ═══════════════════════════════════════════════════════════════════════════════
# GUI APPLICATION
# ═══════════════════════════════════════════════════════════════════════════════
class BetterCFApp:
def __init__(self):
self.root = tk.Tk()
self.root.title("BetterCurseForge Patcher")
self.root.geometry("780x560")
self.root.minsize(720, 500)
self.root.configure(bg=COLORS["bg"])
self.install_dir = None
self.is_working = False
self._configure_styles()
self._build_ui()
self._refresh_status()
def _configure_styles(self):
style = ttk.Style()
style.theme_use("clam")
style.configure(".", background=COLORS["bg"], foreground=COLORS["text"], font=("Segoe UI", 10))
style.configure("TFrame", background=COLORS["bg"])
style.configure("Card.TFrame", background=COLORS["bg_card"])
style.configure("CardTitle.TLabel", font=("Segoe UI", 11, "bold"), foreground=COLORS["text"])
style.configure("StatusKey.TLabel", font=("Segoe UI", 9), foreground=COLORS["text_dim"])
style.configure("StatusVal.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["text"])
style.configure("Patched.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["green"])
style.configure("NotPatched.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["red"])
style.configure("Error.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["red"])
def _build_ui(self):
# Header
header = tk.Frame(self.root, bg=COLORS["bg_card"], padx=20, pady=12)
header.pack(fill="x")
title_frame = tk.Frame(header, bg=COLORS["bg_card"])
title_frame.pack(side="left")
title_lbl = tk.Label(
title_frame, text="⬡ BetterCurseForge", font=("Segoe UI", 16, "bold"),
bg=COLORS["bg_card"], fg=COLORS["purple_glow"]
)
title_lbl.pack(side="left")
ver_lbl = tk.Label(
title_frame, text=f"v{VERSION}", font=("Segoe UI", 9),
bg=COLORS["bg_card"], fg=COLORS["text_dim"]
)
ver_lbl.pack(side="left", padx=(8, 0), pady=(4, 0))
sub_lbl = tk.Label(
header, text="Ad-free launcher with custom themes and plugins", font=("Segoe UI", 9),
bg=COLORS["bg_card"], fg=COLORS["text_dim"]
)
sub_lbl.pack(side="left", padx=(16, 0), pady=(4, 0))
# Main Layout (2 Columns)
main = ttk.Frame(self.root, padding="14")
main.pack(fill="both", expand=True)
left = ttk.Frame(main)
left.pack(side="left", fill="both", expand=False, padx=(0, 10))
# ── Status Card ──
status_card = tk.Frame(left, bg=COLORS["bg_card"], highlightbackground=COLORS["border"],
highlightthickness=1, padx=14, pady=12)
status_card.pack(fill="x", pady=(0, 10))
ttk.Label(status_card, text="📋 Launcher Status", style="CardTitle.TLabel").pack(anchor="w")
tk.Frame(status_card, height=1, bg=COLORS["border"]).pack(fill="x", pady=(6, 8))
self.status_grid = tk.Frame(status_card, bg=COLORS["bg_card"])
self.status_grid.pack(fill="x")
self._status_labels = {}
for i, (key, label) in enumerate([
("install", "Install Path"),
("status", "Patch Status"),
("watcher", "Auto-Repatch"),
("themes", "Themes"),
("plugins", "Plugins"),
]):
ttk.Label(self.status_grid, text=label, style="StatusKey.TLabel").grid(
row=i, column=0, sticky="w", pady=2, padx=(0, 12))
lbl = ttk.Label(self.status_grid, text="—", style="StatusVal.TLabel")
lbl.grid(row=i, column=1, sticky="w", pady=2)
self._status_labels[key] = lbl
# ── Action Buttons ──
btn_frame = tk.Frame(left, bg=COLORS["bg"])
btn_frame.pack(fill="x")
self.patch_btn = tk.Button(
btn_frame, text="⚡ Patch CurseForge", font=("Segoe UI", 11, "bold"),
bg=COLORS["purple"], fg="#ffffff", activebackground=COLORS["purple_hover"],
activeforeground="#ffffff", relief="flat", cursor="hand2", pady=9,
command=self._on_patch,
)
self.patch_btn.pack(fill="x", pady=(0, 5))
self.unpatch_btn = tk.Button(
btn_frame, text="🔄 Restore Vanilla", font=("Segoe UI", 10),
bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2", pady=7,
command=self._on_unpatch,
)
self.unpatch_btn.pack(fill="x", pady=(0, 5))
self.watcher_btn = tk.Button(
btn_frame, text="🛡️ Auto-Repatch on Update", font=("Segoe UI", 10),
bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2", pady=7,
command=self._on_toggle_watcher,
)
self.watcher_btn.pack(fill="x", pady=(0, 8))
# Secondary Actions
sub_row = tk.Frame(btn_frame, bg=COLORS["bg"])
sub_row.pack(fill="x")
self.launch_btn = tk.Button(
sub_row, text="🚀 Launch", font=("Segoe UI", 9),
bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=10, pady=5,
command=self._on_launch,
)
self.launch_btn.pack(side="left", padx=(0, 4), fill="x", expand=True)
self.themes_btn = tk.Button(
sub_row, text="🎨 Themes", font=("Segoe UI", 9),
bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=8, pady=5,
command=self._on_open_themes,
)
self.themes_btn.pack(side="left", padx=(0, 4), fill="x", expand=True)
self.plugins_btn = tk.Button(
sub_row, text="🔌 Plugins", font=("Segoe UI", 9),
bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=8, pady=5,
command=self._on_open_plugins,
)
self.plugins_btn.pack(side="left", fill="x", expand=True)
# ── Right Column: Logs ──
right = ttk.Frame(main)
right.pack(side="right", fill="both", expand=True)
log_card = tk.Frame(right, bg=COLORS["bg_card"], highlightbackground=COLORS["border"],
highlightthickness=1, padx=12, pady=10)
log_card.pack(fill="both", expand=True)
log_header = tk.Frame(log_card, bg=COLORS["bg_card"])
log_header.pack(fill="x", pady=(0, 6))
ttk.Label(log_header, text="📝 Activity Log", style="CardTitle.TLabel").pack(side="left")
clear_btn = tk.Button(
log_header, text="Clear", font=("Segoe UI", 8),
bg=COLORS["bg_card"], fg=COLORS["text_muted"], activebackground=COLORS["bg_card"],
activeforeground=COLORS["text"], relief="flat", cursor="hand2",
command=self._clear_log
)
clear_btn.pack(side="right")
self.log_text = scrolledtext.ScrolledText(
log_card, wrap="word", font=("Cascadia Code", 9), height=18,
bg=COLORS["bg_input"], fg=COLORS["text_dim"], insertbackground=COLORS["text"],
relief="flat", highlightbackground=COLORS["border"], highlightthickness=1,
padx=8, pady=8,
)
self.log_text.pack(fill="both", expand=True)
self.log_text.tag_configure("timestamp", foreground=COLORS["text_muted"])
self.log_text.tag_configure("info", foreground=COLORS["text"])
self.log_text.tag_configure("success", foreground=COLORS["green"])
self.log_text.tag_configure("error", foreground=COLORS["red"])
self.log_text.tag_configure("warn", foreground=COLORS["yellow"])
self.log_text.tag_configure("header", foreground=COLORS["purple_glow"], font=("Segoe UI", 9, "bold"))
self.log_text.configure(state="disabled")
# ── Logging ─────────────────────────────────────────────────────────────
def log(self, message: str, level: str = "info"):
ts = datetime.now().strftime("%H:%M:%S")
self.log_text.configure(state="normal")
self.log_text.insert("end", f"[{ts}] ", "timestamp")
tag = level
if message.startswith("[+]") or message.startswith("✓") or "success" in message.lower():
tag = "success"
elif message.startswith("[-]") or message.startswith("✗") or "fail" in message.lower() or "error" in message.lower():
tag = "error"
elif message.startswith("[!]") or "warn" in message.lower():
tag = "warn"
self.log_text.insert("end", f"{message}\n", tag)
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _clear_log(self):
self.log_text.configure(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.configure(state="disabled")
# ── Refresh Status ──────────────────────────────────────────────────────
def _refresh_status(self):
self.install_dir = patch_core.get_curseforge_dir()
if not self.install_dir:
self._status_labels["install"].configure(text="Not Found", style="Error.TLabel")
self._status_labels["status"].configure(text="N/A", style="StatusVal.TLabel")
self.patch_btn.configure(state="disabled")
self.unpatch_btn.configure(state="disabled")
self.log("CurseForge installation not found!", "error")
return
path_str = str(self.install_dir)
if len(path_str) > 32:
path_str = "..." + path_str[-29:]
self._status_labels["install"].configure(text=path_str, style="StatusVal.TLabel")
is_patched = patch_core.is_curseforge_patched(self.install_dir)
if is_patched:
self._status_labels["status"].configure(text="✓ PATCHED", style="Patched.TLabel")
self.patch_btn.configure(text="🔁 Re-Patch CurseForge")
self.unpatch_btn.configure(state="normal")
else:
self._status_labels["status"].configure(text="✗ NOT PATCHED", style="NotPatched.TLabel")
self.patch_btn.configure(text="⚡ Patch CurseForge")
self.unpatch_btn.configure(state="disabled")
# Watcher status
autostart = patch_core.is_autostart_watcher_enabled()
running = patch_core.is_watcher_running()
if is_patched and not running and autostart:
patch_core.start_background_watcher()
running = patch_core.is_watcher_running()
if running:
self._status_labels["watcher"].configure(text="Active (Instant)", style="Patched.TLabel")
self.watcher_btn.configure(text="🛡️ Auto-Repatch: ON", bg=COLORS["green_dark"], fg="#ffffff")
elif autostart:
self._status_labels["watcher"].configure(text="Enabled (Idle)", style="Patched.TLabel")
self.watcher_btn.configure(text="🛡️ Auto-Repatch: ON", bg=COLORS["green_dark"], fg="#ffffff")
else:
self._status_labels["watcher"].configure(text="Inactive", style="StatusKey.TLabel")
self.watcher_btn.configure(text="🛡️ Enable Auto-Repatch", bg=COLORS["bg_elevated"], fg=COLORS["text_dim"])
# Themes & Plugins counts
user_dir = patch_core.get_user_data_dir()
n_themes = len(list((user_dir / "themes").glob("*.css")))
n_plugins = len([p for p in (user_dir / "plugins").iterdir() if p.is_dir()])
self._status_labels["themes"].configure(text=f"{n_themes} installed")
self._status_labels["plugins"].configure(text=f"{n_plugins} installed")
self.patch_btn.configure(state="normal")
# ── Actions ─────────────────────────────────────────────────────────────
def _on_patch(self):
if self.is_working:
return
self.is_working = True
self.patch_btn.configure(state="disabled")
self.unpatch_btn.configure(state="disabled")
def task():
self.log("Starting patch process...", "header")
success = patch_core.patch_curseforge(log_callback=lambda msg: self.root.after(0, self.log, msg))
if success:
self.root.after(0, self.log, "[+] Auto-repatch active. Watcher running in background.", "success")
self.root.after(0, self._after_action)
threading.Thread(target=task, daemon=True).start()
def _on_unpatch(self):
if self.is_working:
return
self.is_working = True
self.patch_btn.configure(state="disabled")
self.unpatch_btn.configure(state="disabled")
def task():
self.log("Restoring vanilla CurseForge...", "header")
success = patch_core.unpatch_curseforge(log_callback=lambda msg: self.root.after(0, self.log, msg))
self.root.after(0, self._after_action)
threading.Thread(target=task, daemon=True).start()
def _after_action(self):
self.is_working = False
self._refresh_status()
def _on_toggle_watcher(self):
current = patch_core.is_autostart_watcher_enabled() or patch_core.is_watcher_running()
new_state = not current
patch_core.set_autostart_watcher(new_state)
if new_state:
patch_core.start_background_watcher()
self.log("[+] Auto-repatch enabled. Watching resources/ directory for updates.", "success")
else:
patch_core.stop_background_watcher()
self.log("[-] Auto-repatch disabled.", "warn")
self._refresh_status()
def _on_launch(self):
if not self.install_dir:
return
exe = self.install_dir / "CurseForge.exe"
if exe.exists():
self.log(f"Launching CurseForge: {exe}")
creation_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
subprocess.Popen([str(exe)], cwd=str(self.install_dir), creationflags=creation_flags)
else:
self.log("CurseForge.exe not found!", "error")
def _on_open_themes(self):
user_dir = patch_core.get_user_data_dir()
themes_dir = user_dir / "themes"
os.startfile(str(themes_dir))
self.log(f"Opened themes folder: {themes_dir}")
def _on_open_plugins(self):
user_dir = patch_core.get_user_data_dir()
plugins_dir = user_dir / "plugins"
os.startfile(str(plugins_dir))
self.log(f"Opened plugins folder: {plugins_dir}")
def run(self):
self.log(f"BetterCurseForge Patcher v{VERSION} ready.")
self.root.mainloop()
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN ENTRY POINT
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
if arg == "--watch":
# Silent background watcher mode
patch_core.run_watcher_loop()
sys.exit(0)
elif arg == "--wait-for-update":
pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
patch_core.wait_for_update_and_repatch(pid)
sys.exit(0)
elif arg == "--ensure-watcher":
if not patch_core.is_watcher_running():
patch_core.start_background_watcher()
sys.exit(0)
elif arg == "patch":
patch_core.patch_curseforge()
sys.exit(0)
elif arg == "unpatch":
patch_core.unpatch_curseforge()
sys.exit(0)
elif arg == "status":
cf = patch_core.get_curseforge_dir()
print("CurseForge:", cf)
print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False)
print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED")
sys.exit(0)
app = BetterCFApp()
app.run()

16
patcher/vendor/asar/node_modules/.bin/asar generated vendored Normal file
View file

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../@electron/asar/bin/asar.mjs" "$@"
else
exec node "$basedir/../@electron/asar/bin/asar.mjs" "$@"
fi

17
patcher/vendor/asar/node_modules/.bin/asar.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@electron\asar\bin\asar.mjs" %*

28
patcher/vendor/asar/node_modules/.bin/asar.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../@electron/asar/bin/asar.mjs" $args
} else {
& "$basedir/node$exe" "$basedir/../@electron/asar/bin/asar.mjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../@electron/asar/bin/asar.mjs" $args
} else {
& "node$exe" "$basedir/../@electron/asar/bin/asar.mjs" $args
}
$ret=$LASTEXITCODE
}
exit $ret

110
patcher/vendor/asar/node_modules/.package-lock.json generated vendored Normal file
View file

@ -0,0 +1,110 @@
{
"name": "4b0e2640fe917ac8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@electron/asar": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.1.tgz",
"integrity": "sha512-6pI64z/tMSBkUTZLQFYhs2YiB7fS9sKA6gy7/2nH1Ju7GGwT32UPsj4Mxsws5NMmtR6C0zYdXVxEU/rGqJRCJA==",
"license": "MIT",
"dependencies": {
"glob": "^13.0.2",
"minimatch": "^10.0.1"
},
"bin": {
"asar": "bin/asar.mjs"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
}
}
}

View file

@ -0,0 +1,20 @@
Copyright (c) 2014 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,233 @@
# @electron/asar - Electron Archive
[![Test](https://github.com/electron/asar/actions/workflows/test.yml/badge.svg)](https://github.com/electron/asar/actions/workflows/test.yml)
[![npm version](http://img.shields.io/npm/v/@electron/asar.svg)](https://npmjs.org/package/@electron/asar)
[![API docs](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2F%40electron%2Fasar%2Flatest&query=%24.version&logo=typescript&logoColor=white&label=API%20Docs)](https://packages.electronjs.org/asar)
ASAR is a simple extensive archive format. It concatenates all files together without compression
(like [`tar`](https://www.gnu.org/software/tar/)) while having random access support.
## Features
* Support random access
* Use JSON to store file information
* Very easy to write a parser
* Store the contents of duplicated files only once
## CLI
### Install
This module requires Node 22.12.0 or later.
```bash
npm install --engine-strict @electron/asar
```
### Usage
```bash
$ asar --help
Usage: asar [options] [command]
Commands:
pack|p <dir> <output>
create asar archive
list|l <archive>
list files of asar archive
extract-file|ef <archive> <filename>
extract one file from archive
extract|e <archive> <dest>
extract archive
Options:
-h, --help output usage information
-V, --version output the version number
```
#### Excluding multiple resources from being packed
Given:
```text
app
(a) ├── x1
(b) ├── x2
(c) ├── y3
(d) │   ├── x1
(e) │   └── z1
(f) │   └── x2
(g) └── z4
(h) └── w1
```
Exclude: a, b
```bash
asar pack app app.asar --unpack-dir "{x1,x2}"
```
Exclude: a, b, d, f
```bash
asar pack app app.asar --unpack-dir "**/{x1,x2}"
```
Exclude: a, b, d, f, h
```bash
asar pack app app.asar --unpack-dir "{**/x1,**/x2,z4/w1}"
```
## Programmatic usage
For full API usage, see the [API documentation](https://packages.electronjs.org/asar).
### Example
```javascript
import { createPackage } from '@electron/asar';
const src = 'some/path/';
const dest = 'name.asar';
await createPackage(src, dest);
console.log('done.');
```
Please note that there is currently **no** error handling provided!
### Deduplication
Files with identical contents are stored once and shared: the first copy is
written into the archive and every other copy's header entry points at that same
`offset`. Nothing changes for readers — each file still has its own entry, size,
integrity hash, and executable bit — but archives with duplicated contents (a
common shape for bundled `node_modules`) get smaller and pack faster, since the
redundant bytes are never written.
Unpacked files (`unpack` / `unpackDir`) are always written out in full, because
they live on disk outside the archive.
### Transform
You can pass in a `transform` option, that is a function, which either returns
nothing, or a `stream.Transform`. The latter will be used on files that will be
in the `.asar` file to transform them (e.g. compress).
```javascript
import { createPackageWithOptions } from '@electron/asar';
const src = 'some/path/';
const dest = 'name.asar';
function transform (filename) {
return new CustomTransformStream()
}
await createPackageWithOptions(src, dest, { transform: transform });
console.log('done.');
```
## Format
Asar uses [Pickle][pickle] to safely serialize binary value to file.
The format of asar is very flat:
```markdown
| UInt32: header_size | String: header | Bytes: file1 | ... | Bytes: file42 |
```
The `header_size` and `header` are serialized with [Pickle][pickle] class, and
`header_size`'s [Pickle][pickle] object is 8 bytes.
The `header` is a JSON string, and the `header_size` is the size of `header`'s
`Pickle` object.
Structure of `header` is something like this:
```json
{
"files": {
"tmp": {
"files": {}
},
"usr" : {
"files": {
"bin": {
"files": {
"ls": {
"offset": "0",
"size": 100,
"executable": true,
"integrity": {
"algorithm": "SHA256",
"hash": "...",
"blockSize": 1024,
"blocks": ["...", "..."]
}
},
"cd": {
"offset": "100",
"size": 100,
"executable": true,
"integrity": {
"algorithm": "SHA256",
"hash": "...",
"blockSize": 1024,
"blocks": ["...", "..."]
}
}
}
}
}
},
"etc": {
"files": {
"hosts": {
"offset": "200",
"size": 32,
"integrity": {
"algorithm": "SHA256",
"hash": "...",
"blockSize": 1024,
"blocks": ["...", "..."]
}
}
}
}
}
}
```
`offset` and `size` records the information to read the file from archive, the
`offset` starts from 0 so you have to manually add the size of `header_size` and
`header` to the `offset` to get the real offset of the file.
Files with identical contents share a single copy in the archive, so more than
one entry can point at the same `offset`.
`offset` is a UINT64 number represented in string, because there is no way to
precisely represent UINT64 in JavaScript `Number`. `size` is a JavaScript
`Number` that is no larger than `Number.MAX_SAFE_INTEGER`, which has a value of
`9007199254740991` and is about 8PB in size. We didn't store `size` in UINT64
because file size in Node.js is represented as `Number` and it is not safe to
convert `Number` to UINT64.
`integrity` is an object consisting of a few keys:
* A hashing `algorithm`, currently only `SHA256` is supported.
* A hex encoded `hash` value representing the hash of the entire file.
* An array of hex encoded hashes for the `blocks` of the file (i.e. for a blockSize of 4KB, this array contains the hash of every block if you split the file into N 4KB blocks).
* A integer value `blockSize` representing the size in bytes of each block in the `blocks` hashes above.
[pickle]: https://chromium.googlesource.com/chromium/src/+/main/base/pickle.h

View file

@ -0,0 +1,182 @@
#!/usr/bin/env node
import packageJSON from '../package.json' with { type: 'json' };
import { createPackageWithOptions, listPackage, extractFile, extractAll } from '../lib/asar.js';
import { parseArgs } from 'node:util';
import fs from 'node:fs';
import path from 'node:path';
const splitVersion = function (version) {
return version.split('.').map(function (part) {
return Number(part);
});
};
const requiredNodeVersion = splitVersion(packageJSON.engines.node.slice(2));
const actualNodeVersion = splitVersion(process.versions.node);
if (
actualNodeVersion[0] < requiredNodeVersion[0] ||
(actualNodeVersion[0] === requiredNodeVersion[0] && actualNodeVersion[1] < requiredNodeVersion[1])
) {
console.error('CANNOT RUN WITH NODE ' + process.versions.node);
console.error('asar requires Node ' + packageJSON.engines.node + '.');
process.exit(1);
}
const commands = {
pack: {
aliases: ['p'],
usage: 'pack|p [options] <dir> <output>',
description: 'create asar archive',
args: ['dir', 'output'],
options: {
ordering: { type: 'string', description: 'path to a text file for ordering contents' },
unpack: { type: 'string', description: 'do not pack files matching glob <expression>' },
'unpack-dir': {
type: 'string',
description:
'do not pack dirs matching glob <expression> or starting with literal <expression>',
},
'exclude-hidden': { type: 'boolean', description: 'exclude hidden files' },
},
action: (positionals, values) => {
const [dir, output] = positionals;
const options = {
unpack: values.unpack,
unpackDir: values['unpack-dir'],
ordering: values.ordering,
dot: !values['exclude-hidden'],
};
return createPackageWithOptions(dir, output, options);
},
},
list: {
aliases: ['l'],
usage: 'list|l [options] <archive>',
description: 'list files of asar archive',
args: ['archive'],
options: {
'is-pack': {
type: 'boolean',
short: 'i',
description: 'each file in the asar is pack or unpack',
},
},
action: (positionals, values) => {
const [archive] = positionals;
const files = listPackage(archive, { isPack: values['is-pack'] });
for (const i in files) {
console.log(files[i]);
}
},
},
'extract-file': {
aliases: ['ef'],
usage: 'extract-file|ef <archive> <filename>',
description: 'extract one file from archive',
args: ['archive', 'filename'],
options: {},
action: (positionals) => {
const [archive, filename] = positionals;
fs.writeFileSync(path.basename(filename), extractFile(archive, filename));
},
},
extract: {
aliases: ['e'],
usage: 'extract|e <archive> <dest>',
description: 'extract archive',
args: ['archive', 'dest'],
options: {},
action: (positionals) => {
const [archive, dest] = positionals;
extractAll(archive, dest);
},
},
};
function printHelp() {
console.log('Usage: asar [options] [command]');
console.log();
console.log('Manipulate asar archive files');
console.log();
console.log('Options:');
console.log(' -V, --version output the version number');
console.log(' -h, --help display help for command');
console.log();
console.log('Commands:');
for (const [name, cmd] of Object.entries(commands)) {
const label = `${name}|${cmd.aliases[0]}`;
console.log(` ${label.padEnd(32)} ${cmd.description}`);
}
}
function printCommandHelp(cmd) {
console.log(`Usage: asar ${cmd.usage}`);
console.log();
console.log(cmd.description);
console.log();
console.log('Options:');
for (const [opt, spec] of Object.entries(cmd.options)) {
const prefix = spec.short ? `-${spec.short}, ` : '';
const suffix = spec.type === 'string' ? ' <value>' : '';
const label = `${prefix}--${opt}${suffix}`;
console.log(` ${label.padEnd(32)} ${spec.description}`);
}
console.log(` ${'-h, --help'.padEnd(32)} display help for command`);
}
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
printHelp();
process.exit(0);
}
if (args[0] === '--version' || args[0] === '-V') {
console.log('v' + packageJSON.version);
process.exit(0);
}
const commandName = args[0];
const commandArgs = args.slice(1);
let command = commands[commandName];
if (!command) {
command = Object.values(commands).find((cmd) => cmd.aliases.includes(commandName));
}
if (!command) {
console.log("asar: '%s' is not an asar command. See 'asar --help'.", commandName);
process.exit(1);
}
let values, positionals;
try {
({ values, positionals } = parseArgs({
args: commandArgs,
options: {
...command.options,
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
}));
} catch (error) {
console.error(`error: ${error.message}`);
process.exit(1);
}
if (values.help) {
printCommandHelp(command);
process.exit(0);
}
if (positionals.length < command.args.length) {
const missing = command.args[positionals.length];
console.error(`error: missing required argument '${missing}'`);
process.exit(1);
}
Promise.resolve(command.action(positionals, values)).catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,75 @@
import { FilesystemEntry } from './filesystem.js';
import { InputMetadata } from './disk.js';
import { CrawledFileType } from './crawlfs.js';
import { GlobOptionsWithFileTypesFalse } from 'glob';
export declare function createPackage(src: string, dest: string): Promise<void>;
export type CreateOptions = {
dot?: boolean;
globOptions?: GlobOptionsWithFileTypesFalse;
/**
* Path to a file containing the list of relative filepaths relative to `src` and the specific order they should be inserted into the asar.
* Formats allowed below:
* filepath
* : filepath
* <anything>:filepath
*/
ordering?: string;
pattern?: string;
transform?: (filePath: string) => NodeJS.ReadWriteStream | void;
unpack?: string;
unpackDir?: string;
};
export declare function createPackageWithOptions(src: string, dest: string, options: CreateOptions): Promise<void>;
/**
* Create an ASAR archive from a list of filenames.
*
* @param src - Base path. All files are relative to this.
* @param dest - Archive filename (& path).
* @param filenames - List of filenames relative to src.
* @param [metadata] - Object with filenames as keys and {type='directory|file|link', stat: fs.stat} as values. (Optional)
* @param [options] - Options passed to `createPackageWithOptions`.
*/
export declare function createPackageFromFiles(src: string, dest: string, filenames: string[], metadata?: InputMetadata, options?: CreateOptions): Promise<void>;
export type AsarStream = {
/**
Relative path to the file or directory from within the archive
*/
path: string;
/**
Function that returns a read stream for a file.
Note: this is called multiple times per "file", so a new NodeJS.ReadableStream needs to be created each time
*/
streamGenerator: () => NodeJS.ReadableStream;
/**
Whether the file/link should be unpacked
*/
unpacked: boolean;
stat: CrawledFileType['stat'];
};
export type AsarDirectory = Pick<AsarStream, 'path' | 'unpacked'> & {
type: 'directory';
};
export type AsarSymlinkStream = AsarStream & {
type: 'link';
symlink: string;
};
export type AsarFileStream = AsarStream & {
type: 'file';
};
export type AsarStreamType = AsarDirectory | AsarFileStream | AsarSymlinkStream;
/**
* Create an ASAR archive from a list of streams.
*
* @param dest - Archive filename (& path).
* @param streams - List of streams to be piped in-memory into asar filesystem. Insertion order is preserved.
*/
export declare function createPackageFromStreams(dest: string, streams: AsarStreamType[]): Promise<void>;
export declare function statFile(archivePath: string, filename: string, followLinks?: boolean): FilesystemEntry;
export declare function getRawHeader(archivePath: string): import("./disk.js").ArchiveHeader;
export interface ListOptions {
isPack: boolean;
}
export declare function listPackage(archivePath: string, options: ListOptions): string[];
export declare function extractFile(archivePath: string, filename: string, followLinks?: boolean): Buffer<ArrayBuffer>;
export declare function extractAll(archivePath: string, dest: string): void;
export { uncacheAll, uncacheFilesystem as uncache, FileRecord, DirectoryRecord } from './disk.js';

View file

@ -0,0 +1,312 @@
import path from 'node:path';
import { minimatch } from 'minimatch';
import { wrappedFs as fs } from './wrapped-fs.js';
import { Filesystem } from './filesystem.js';
import { ensureWithin } from './path-validation.js';
import { readArchiveHeaderSync, readFilesystemSync, readFileSync, streamFilesystem, writeFilesystem, } from './disk.js';
import { crawl as crawlFilesystem, determineFileType } from './crawlfs.js';
/**
* Whether a directory should be excluded from packing due to the `--unpack-dir" option.
*
* @param dirPath - directory path to check
* @param pattern - literal prefix [for backward compatibility] or glob pattern
* @param unpackDirs - Array of directory paths previously marked as unpacked
*/
function isUnpackedDir(dirPath, pattern, unpackDirs) {
if (dirPath.startsWith(pattern) || minimatch(dirPath, pattern)) {
unpackDirs.add(dirPath);
return true;
}
else {
for (const unpackDir of unpackDirs) {
if (dirPath.startsWith(unpackDir) && !path.relative(unpackDir, dirPath).startsWith('..')) {
return true;
}
}
return false;
}
}
export async function createPackage(src, dest) {
return createPackageWithOptions(src, dest, {});
}
export async function createPackageWithOptions(src, dest, options) {
const globOptions = options.globOptions ? options.globOptions : {};
globOptions.dot = options.dot === undefined ? true : options.dot;
const pattern = src + (options.pattern ? options.pattern : '/**/*');
const [filenames, metadata] = await crawlFilesystem(pattern, globOptions);
return createPackageFromFiles(src, dest, filenames, metadata, options);
}
/**
* Create an ASAR archive from a list of filenames.
*
* @param src - Base path. All files are relative to this.
* @param dest - Archive filename (& path).
* @param filenames - List of filenames relative to src.
* @param [metadata] - Object with filenames as keys and {type='directory|file|link', stat: fs.stat} as values. (Optional)
* @param [options] - Options passed to `createPackageWithOptions`.
*/
export async function createPackageFromFiles(src, dest, filenames, metadata = {}, options = {}) {
src = path.normalize(src);
dest = path.normalize(dest);
filenames = filenames.map(function (filename) {
return path.normalize(filename);
});
const filesystem = new Filesystem(src);
const files = [];
const links = [];
const unpackDirs = new Set();
let filenamesSorted = [];
if (options.ordering) {
const orderingFiles = (await fs.readFile(options.ordering))
.toString()
.split('\n')
.map((line) => {
if (line.includes(':')) {
line = line.split(':').pop();
}
line = line.trim();
if (line.startsWith('/')) {
line = line.slice(1);
}
return line;
});
const ordering = [];
for (const file of orderingFiles) {
const pathComponents = file.split(path.sep);
let str = src;
for (const pathComponent of pathComponents) {
str = path.join(str, pathComponent);
ordering.push(str);
}
}
let missing = 0;
const total = filenames.length;
const filenameSet = new Set(filenames);
const sortedSet = new Set();
for (const file of ordering) {
if (!sortedSet.has(file) && filenameSet.has(file)) {
filenamesSorted.push(file);
sortedSet.add(file);
}
}
for (const file of filenames) {
if (!sortedSet.has(file)) {
filenamesSorted.push(file);
sortedSet.add(file);
missing += 1;
}
}
console.log(`Ordering file has ${((total - missing) / total) * 100}% coverage.`);
}
else {
filenamesSorted = filenames;
}
const shouldUnpackPath = function (filename, relativePath, unpack, unpackDir) {
let shouldUnpack = false;
if (unpack) {
shouldUnpack = minimatch(filename, unpack, { matchBase: true });
}
if (!shouldUnpack && unpackDir) {
shouldUnpack = isUnpackedDir(relativePath, unpackDir, unpackDirs);
}
return shouldUnpack;
};
// Batch-resolve metadata for files missing it (parallelized)
const missingMetadata = filenamesSorted.filter((f) => !metadata[f]);
if (missingMetadata.length > 0) {
const resolved = await Promise.all(missingMetadata.map(async (filename) => {
const fileType = await determineFileType(filename);
if (!fileType) {
throw new Error('Unknown file type for file: ' + filename);
}
return [filename, fileType];
}));
for (const [filename, fileType] of resolved) {
metadata[filename] = fileType;
}
}
// Process files in original sorted order to preserve header key ordering
for (const filename of filenamesSorted) {
const file = metadata[filename];
let shouldUnpack;
switch (file.type) {
case 'directory':
shouldUnpack = shouldUnpackPath(filename, path.relative(src, filename), undefined, options.unpackDir);
filesystem.insertDirectory(filename, shouldUnpack);
break;
case 'file': {
shouldUnpack = shouldUnpackPath(filename, path.relative(src, path.dirname(filename)), options.unpack, options.unpackDir);
const duplicate = await filesystem.insertFile(filename, () => fs.createReadStream(filename), shouldUnpack, file, options);
files.push({ filename, unpack: shouldUnpack, duplicate });
break;
}
case 'link':
shouldUnpack = shouldUnpackPath(filename, path.relative(src, filename), options.unpack, options.unpackDir);
links.push({ filename, unpack: shouldUnpack });
filesystem.insertLink(filename, shouldUnpack);
break;
}
}
await fs.mkdirp(path.dirname(dest));
return writeFilesystem(dest, filesystem, { files, links }, metadata);
}
/**
* Create an ASAR archive from a list of streams.
*
* @param dest - Archive filename (& path).
* @param streams - List of streams to be piped in-memory into asar filesystem. Insertion order is preserved.
*/
export async function createPackageFromStreams(dest, streams) {
// We use an ambiguous root `src` since we're piping directly from a stream and the `filePath` for the stream is already relative to the src/root
const src = '.';
const filesystem = new Filesystem(src);
const files = [];
const links = [];
const handleFile = async function (stream) {
const { path: destinationPath, type } = stream;
const filename = path.normalize(destinationPath);
switch (type) {
case 'directory':
filesystem.insertDirectory(filename, stream.unpacked);
break;
case 'file': {
const duplicate = await filesystem.insertFile(filename, stream.streamGenerator, stream.unpacked, { type: 'file', stat: stream.stat },
// `filename` is the destination path inside the archive, not a path
// on disk, so integrity must be computed from the stream.
{ fromStream: true });
files.push({
filename,
streamGenerator: stream.streamGenerator,
link: undefined,
mode: stream.stat.mode,
unpack: stream.unpacked,
duplicate,
});
break;
}
case 'link':
links.push({
filename,
streamGenerator: stream.streamGenerator,
link: stream.symlink,
mode: stream.stat.mode,
unpack: stream.unpacked,
});
filesystem.insertLink(filename, stream.unpacked, path.dirname(filename), stream.symlink, src);
break;
}
return Promise.resolve();
};
const insertsDone = async function () {
await fs.mkdirp(path.dirname(dest));
return streamFilesystem(dest, filesystem, { files, links });
};
const streamQueue = streams.slice();
const next = async function (stream) {
if (!stream) {
return insertsDone();
}
await handleFile(stream);
return next(streamQueue.shift());
};
return next(streamQueue.shift());
}
export function statFile(archivePath, filename, followLinks = true) {
const filesystem = readFilesystemSync(archivePath);
return filesystem.getFile(filename, followLinks);
}
export function getRawHeader(archivePath) {
return readArchiveHeaderSync(archivePath);
}
export function listPackage(archivePath, options) {
return readFilesystemSync(archivePath).listFiles(options);
}
export function extractFile(archivePath, filename, followLinks = true) {
const filesystem = readFilesystemSync(archivePath);
const fileInfo = filesystem.getFile(filename, followLinks);
if ('link' in fileInfo || 'files' in fileInfo) {
throw new Error('Expected to find file at: ' + filename + ' but found a directory or link');
}
return readFileSync(filesystem, filename, fileInfo);
}
export function extractAll(archivePath, dest) {
const filesystem = readFilesystemSync(archivePath);
const filenames = filesystem.listFiles();
// under windows just extract links as regular files
const followLinks = process.platform === 'win32';
// create destination directory
fs.mkdirpSync(dest);
// Read the entire data section at once — one syscall instead of one per file.
const headerSize = filesystem.getHeaderSize();
const archiveSize = fs.statSync(archivePath).size;
const dataStart = 8 + headerSize;
const dataSize = archiveSize - dataStart;
let dataBuf = null;
if (dataSize > 0) {
dataBuf = Buffer.alloc(dataSize);
const fd = fs.openSync(archivePath, 'r');
try {
fs.readSync(fd, dataBuf, 0, dataSize, dataStart);
}
finally {
fs.closeSync(fd);
}
}
const extractionErrors = [];
for (const fullPath of filenames) {
// Remove leading slash
const filename = fullPath.substr(1);
const destFilename = ensureWithin(dest, filename);
const file = filesystem.getFile(filename, followLinks);
if ('files' in file) {
// it's a directory, create it and continue with the next entry
fs.mkdirpSync(destFilename);
}
else if ('link' in file) {
// it's a symlink, create a symlink
const linkSrcPath = path.dirname(path.join(dest, file.link));
const linkDestPath = path.dirname(destFilename);
const relativePath = path.relative(linkDestPath, linkSrcPath);
// try to delete output file, because we can't overwrite a link
try {
fs.unlinkSync(destFilename);
}
catch { }
const linkTo = path.join(relativePath, path.basename(file.link));
if (path.relative(dest, linkSrcPath).startsWith('..')) {
throw new Error(`${fullPath}: file "${file.link}" links out of the package to "${linkSrcPath}"`);
}
fs.symlinkSync(linkTo, destFilename);
}
else {
// it's a file, try to extract it
try {
let content;
if (file.unpacked) {
content = fs.readFileSync(path.join(`${filesystem.getRootPath()}.unpacked`, filename));
}
else if (file.size <= 0) {
content = Buffer.alloc(0);
}
else {
// Slice from the pre-read data buffer — zero-copy view
const offset = parseInt(file.offset);
content = dataBuf.subarray(offset, offset + file.size);
}
fs.writeFileSync(destFilename, content);
if (file.executable) {
fs.chmodSync(destFilename, '755');
}
}
catch (e) {
extractionErrors.push(e);
}
}
}
if (extractionErrors.length) {
throw new Error('Unable to extract some files:\n\n' +
extractionErrors.map((error) => error.stack).join('\n\n'));
}
}
export { uncacheAll, uncacheFilesystem as uncache } from './disk.js';
//# sourceMappingURL=asar.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
import { GlobOptionsWithFileTypesFalse } from 'glob';
import { Stats } from 'node:fs';
export type CrawledFileType = {
type: 'file' | 'directory' | 'link';
stat: Pick<Stats, 'mode' | 'size'>;
transformed?: {
path: string;
stat: Stats;
};
cachedBuffer?: Buffer;
};
export declare function determineFileType(filename: string): Promise<CrawledFileType | null>;
export declare function crawl(dir: string, options: GlobOptionsWithFileTypesFalse): Promise<readonly [string[], Record<string, CrawledFileType>]>;

View file

@ -0,0 +1,58 @@
import { glob } from 'glob';
import { wrappedFs as fs } from './wrapped-fs.js';
import path from 'node:path';
export async function determineFileType(filename) {
const stat = await fs.lstat(filename);
if (stat.isFile()) {
return { type: 'file', stat };
}
else if (stat.isDirectory()) {
return { type: 'directory', stat };
}
else if (stat.isSymbolicLink()) {
return { type: 'link', stat };
}
return null;
}
export async function crawl(dir, options) {
const metadata = {};
// TODO replace with `fs.glob`
const crawled = await glob(dir, {
windowsPathsNoEscape: true,
...options,
});
const results = await Promise.all(crawled.sort().map(async (filename) => [filename, await determineFileType(filename)]));
const links = [];
const linkSet = new Set();
const filenames = results
.map(([filename, type]) => {
if (type) {
metadata[filename] = type;
if (type.type === 'link') {
links.push(filename);
linkSet.add(filename);
}
}
return filename;
})
.filter((filename) => {
if (links.length === 0)
return true;
// Newer glob can return files inside symlinked directories, to avoid
// those appearing in archives we need to manually exclude them here
const isExactLink = linkSet.has(filename);
for (const link of links) {
if (isExactLink && filename === link)
continue;
if (filename.startsWith(link)) {
// symlink may point outside the directory: https://github.com/electron/asar/issues/303
const relativePath = path.relative(link, path.dirname(filename));
if (!relativePath.startsWith('..'))
return false;
}
}
return true;
});
return [filenames, metadata];
}
//# sourceMappingURL=crawlfs.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"crawlfs.js","sourceRoot":"","sources":["../src/crawlfs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAiC,MAAM,MAAM,CAAC;AAE3D,OAAO,EAAE,SAAS,IAAI,EAAE,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,IAAI,MAAM,WAAW,CAAC;AAY7B,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,QAAgB;IACtD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACtC,IAAI,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;QAClB,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAChC,CAAC;SAAM,IAAI,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;QAC9B,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IACrC,CAAC;SAAM,IAAI,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC;QACjC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAChC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,GAAW,EAAE,OAAsC;IAC7E,MAAM,QAAQ,GAAoC,EAAE,CAAC;IACrD,8BAA8B;IAC9B,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,GAAG,EAAE;QAC9B,oBAAoB,EAAE,IAAI;QAC1B,GAAG,OAAO;KACX,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,OAAO,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAU,CAAC,CAC/F,CAAC;IACF,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,MAAM,SAAS,GAAG,OAAO;SACtB,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE;QACxB,IAAI,IAAI,EAAE,CAAC;YACT,QAAQ,CAAC,QAAQ,CAAC,GAAG,IAAI,CAAC;YAC1B,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBACzB,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;gBACrB,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxB,CAAC;QACH,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC,CAAC;SACD,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE;QACnB,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACpC,qEAAqE;QACrE,oEAAoE;QACpE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,WAAW,IAAI,QAAQ,KAAK,IAAI;gBAAE,SAAS;YAC/C,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC9B,uFAAuF;gBACvF,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;gBACjE,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,IAAI,CAAC;oBAAE,OAAO,KAAK,CAAC;YACnD,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;IACL,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAU,CAAC;AACxC,CAAC"}

View file

@ -0,0 +1,50 @@
import { Filesystem, FilesystemFileEntry } from './filesystem.js';
import { CrawledFileType } from './crawlfs.js';
import { Stats } from 'node:fs';
export declare function validateHeader(header: unknown): void;
export type InputMetadata = {
[property: string]: CrawledFileType;
};
export type BasicFilesArray = {
filename: string;
unpack: boolean;
/** Contents shared with an earlier file, already written to the archive */
duplicate?: boolean;
}[];
export type BasicStreamArray = {
filename: string;
streamGenerator: () => NodeJS.ReadableStream;
mode: Stats['mode'];
unpack: boolean;
link: string | undefined;
/** Contents shared with an earlier file, already written to the archive */
duplicate?: boolean;
}[];
export type FilesystemFilesAndLinks<T extends BasicFilesArray | BasicStreamArray> = {
files: T;
links: T;
};
export declare function writeFilesystem(dest: string, filesystem: Filesystem, lists: FilesystemFilesAndLinks<BasicFilesArray>, metadata: InputMetadata): Promise<void>;
export declare function streamFilesystem(dest: string, filesystem: Filesystem, lists: FilesystemFilesAndLinks<BasicStreamArray>): Promise<void>;
export interface FileRecord extends FilesystemFileEntry {
integrity: {
hash: string;
algorithm: 'SHA256';
blocks: string[];
blockSize: number;
};
}
export type DirectoryRecord = {
files: Record<string, DirectoryRecord | FileRecord>;
};
export type ArchiveHeader = {
header: DirectoryRecord;
headerString: string;
headerSize: number;
};
export declare function readArchiveHeaderSync(archivePath: string): ArchiveHeader;
export declare function readFilesystemSync(archivePath: string): Filesystem;
export declare function uncacheFilesystem(archivePath: string): boolean;
export declare function uncacheAll(): void;
export declare function readFileSync(filesystem: Filesystem, filename: string, info: FilesystemFileEntry): Buffer<ArrayBuffer>;
export declare function readFileWithFd(fd: number, filesystem: Filesystem, filename: string, info: FilesystemFileEntry): Buffer<ArrayBuffer>;

View file

@ -0,0 +1,340 @@
import path from 'node:path';
import { wrappedFs as fs } from './wrapped-fs.js';
import { Pickle } from './pickle.js';
import { Filesystem } from './filesystem.js';
import { ensureWithin } from './path-validation.js';
import stream from 'node:stream/promises';
let filesystemCache = Object.create(null);
class HeaderValidationError extends Error {
constructor(path, message) {
super(`Invalid archive header at "${path}": ${message}`);
this.name = 'HeaderValidationError';
}
}
function validateFileEntry(entry, entryPath) {
if (typeof entry.offset !== 'string') {
throw new HeaderValidationError(entryPath, `"offset" must be a string, got ${typeof entry.offset}`);
}
if (!/^\d+$/.test(entry.offset)) {
throw new HeaderValidationError(entryPath, `"offset" must be a numeric string, got "${entry.offset}"`);
}
if (typeof entry.size !== 'number' || !Number.isFinite(entry.size) || entry.size < 0) {
throw new HeaderValidationError(entryPath, `"size" must be a non-negative number, got ${JSON.stringify(entry.size)}`);
}
if (entry.unpacked !== undefined && typeof entry.unpacked !== 'boolean') {
throw new HeaderValidationError(entryPath, `"unpacked" must be a boolean, got ${typeof entry.unpacked}`);
}
if (entry.executable !== undefined && typeof entry.executable !== 'boolean') {
throw new HeaderValidationError(entryPath, `"executable" must be a boolean, got ${typeof entry.executable}`);
}
if (entry.integrity !== undefined) {
validateIntegrity(entry.integrity, entryPath);
}
}
function validateUnpackedFileEntry(entry, entryPath) {
if (typeof entry.size !== 'number' || !Number.isFinite(entry.size) || entry.size < 0) {
throw new HeaderValidationError(entryPath, `"size" must be a non-negative number, got ${JSON.stringify(entry.size)}`);
}
if (entry.unpacked !== true) {
throw new HeaderValidationError(entryPath, `"unpacked" must be true for unpacked file entries`);
}
if (entry.executable !== undefined && typeof entry.executable !== 'boolean') {
throw new HeaderValidationError(entryPath, `"executable" must be a boolean, got ${typeof entry.executable}`);
}
if (entry.integrity !== undefined) {
validateIntegrity(entry.integrity, entryPath);
}
}
function validateIntegrity(integrity, entryPath) {
if (typeof integrity !== 'object' || integrity === null || Array.isArray(integrity)) {
throw new HeaderValidationError(entryPath, `"integrity" must be an object`);
}
const rec = integrity;
if (typeof rec.algorithm !== 'string') {
throw new HeaderValidationError(entryPath, `"integrity.algorithm" must be a string`);
}
if (typeof rec.hash !== 'string') {
throw new HeaderValidationError(entryPath, `"integrity.hash" must be a string`);
}
if (typeof rec.blockSize !== 'number' || !Number.isFinite(rec.blockSize) || rec.blockSize <= 0) {
throw new HeaderValidationError(entryPath, `"integrity.blockSize" must be a positive number`);
}
if (!Array.isArray(rec.blocks)) {
throw new HeaderValidationError(entryPath, `"integrity.blocks" must be an array`);
}
for (let i = 0; i < rec.blocks.length; i++) {
if (typeof rec.blocks[i] !== 'string') {
throw new HeaderValidationError(entryPath, `"integrity.blocks[${i}]" must be a string`);
}
}
}
function validateLinkEntry(entry, entryPath) {
if (typeof entry.link !== 'string') {
throw new HeaderValidationError(entryPath, `"link" must be a string, got ${typeof entry.link}`);
}
if (entry.link.length === 0) {
throw new HeaderValidationError(entryPath, `"link" must not be empty`);
}
}
function validateDirectoryEntry(entry, entryPath) {
if (typeof entry.files !== 'object' || entry.files === null || Array.isArray(entry.files)) {
throw new HeaderValidationError(entryPath, `"files" must be a plain object`);
}
const files = entry.files;
for (const [name, child] of Object.entries(files)) {
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
throw new HeaderValidationError(entryPath, `invalid entry name "${name}"`);
}
const childPath = entryPath === '/' ? `/${name}` : `${entryPath}/${name}`;
validateHeaderEntry(child, childPath);
}
}
function validateHeaderEntry(entry, entryPath) {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new HeaderValidationError(entryPath, 'entry must be an object');
}
const rec = entry;
if ('link' in rec) {
validateLinkEntry(rec, entryPath);
}
else if ('files' in rec) {
validateDirectoryEntry(rec, entryPath);
}
else if ('offset' in rec) {
validateFileEntry(rec, entryPath);
}
else if ('unpacked' in rec && rec.unpacked === true && 'size' in rec) {
validateUnpackedFileEntry(rec, entryPath);
}
else {
throw new HeaderValidationError(entryPath, 'entry must be a directory (with "files"), a file (with "offset" or "unpacked"), or a link (with "link")');
}
}
export function validateHeader(header) {
if (typeof header !== 'object' || header === null || Array.isArray(header)) {
throw new HeaderValidationError('/', 'header must be an object');
}
const rec = header;
if (!('files' in rec)) {
throw new HeaderValidationError('/', 'root header must be a directory with a "files" property');
}
validateDirectoryEntry(rec, '/');
}
async function copyFile(dest, src, filename) {
const srcFile = path.join(src, filename);
const targetFile = path.join(dest, filename);
const [content, stats] = await Promise.all([
fs.readFile(srcFile),
fs.stat(srcFile),
fs.mkdirp(path.dirname(targetFile)),
]);
return fs.writeFile(targetFile, content, { mode: stats.mode });
}
async function streamTransformedFile(stream, outStream) {
return new Promise((resolve, reject) => {
stream.pipe(outStream, { end: false });
stream.on('error', reject);
stream.on('end', () => resolve());
});
}
const writeFileListToStream = async function (dest, filesystem, out, lists, metadata) {
const { files, links } = lists;
// Batch cached buffers into a single write to avoid per-file write syscalls
let pendingBuffers = [];
const flushPendingBuffers = async () => {
if (pendingBuffers.length === 0)
return;
const combined = pendingBuffers.length === 1 ? pendingBuffers[0] : Buffer.concat(pendingBuffers);
pendingBuffers = [];
await new Promise((resolve, reject) => {
out.write(combined, (err) => (err ? reject(err) : resolve()));
});
};
for (const file of files) {
if (file.duplicate) {
continue;
}
if (file.unpack) {
await flushPendingBuffers();
const filename = path.relative(filesystem.getRootPath(), file.filename);
await copyFile(`${dest}.unpacked`, filesystem.getRootPath(), filename);
}
else {
const fileMeta = metadata[file.filename];
if (fileMeta.cachedBuffer) {
pendingBuffers.push(fileMeta.cachedBuffer);
fileMeta.cachedBuffer = undefined;
}
else {
await flushPendingBuffers();
const transformed = fileMeta.transformed;
const stream = fs.createReadStream(transformed ? transformed.path : file.filename);
await streamTransformedFile(stream, out);
}
}
}
await flushPendingBuffers();
for (const file of links.filter((f) => f.unpack)) {
const filename = path.relative(filesystem.getRootPath(), file.filename);
const link = await fs.readlink(file.filename);
await createSymlink(dest, filename, link);
}
return new Promise((resolve, reject) => {
out.on('error', reject);
out.end(() => resolve());
});
};
export async function writeFilesystem(dest, filesystem, lists, metadata) {
const out = await createFilesystemWriteStream(filesystem, dest);
return writeFileListToStream(dest, filesystem, out, lists, metadata);
}
export async function streamFilesystem(dest, filesystem, lists) {
const out = await createFilesystemWriteStream(filesystem, dest);
const { files, links } = lists;
for await (const file of files) {
if (file.duplicate) {
continue;
}
// the file should not be packed into archive
if (file.unpack) {
const targetFile = path.join(`${dest}.unpacked`, file.filename);
await fs.mkdirp(path.dirname(targetFile));
const writeStream = fs.createWriteStream(targetFile, { mode: file.mode });
await stream.pipeline(file.streamGenerator(), writeStream);
}
else {
await streamTransformedFile(file.streamGenerator(), out);
}
}
for (const file of links.filter((f) => f.unpack && f.link)) {
// the symlink needs to be recreated outside in .unpacked
await createSymlink(dest, file.filename, file.link);
}
return new Promise((resolve, reject) => {
out.on('error', reject);
out.end(() => resolve());
});
}
export function readArchiveHeaderSync(archivePath) {
const fd = fs.openSync(archivePath, 'r');
let size;
let headerBuf;
try {
const archiveSize = fs.fstatSync(fd).size;
const sizeBuf = Buffer.alloc(8);
if (fs.readSync(fd, sizeBuf, 0, 8, null) !== 8) {
throw new Error('Unable to read header size');
}
const sizePickle = Pickle.createFromBuffer(sizeBuf);
size = sizePickle.createIterator().readUInt32();
if (size < 0 || size > archiveSize - 8) {
throw new Error(`Header size ${size} exceeds archive size ${archiveSize}. The archive is corrupted.`);
}
headerBuf = Buffer.alloc(size);
if (fs.readSync(fd, headerBuf, 0, size, null) !== size) {
throw new Error('Unable to read header');
}
}
finally {
fs.closeSync(fd);
}
const headerPickle = Pickle.createFromBuffer(headerBuf);
const header = headerPickle.createIterator().readString();
const parsedHeader = JSON.parse(header);
validateHeader(parsedHeader);
return { headerString: header, header: parsedHeader, headerSize: size };
}
export function readFilesystemSync(archivePath) {
if (!filesystemCache[archivePath]) {
const header = readArchiveHeaderSync(archivePath);
const filesystem = new Filesystem(archivePath);
filesystem.setHeader(header.header, header.headerSize);
filesystemCache[archivePath] = filesystem;
}
return filesystemCache[archivePath];
}
export function uncacheFilesystem(archivePath) {
if (filesystemCache[archivePath]) {
filesystemCache[archivePath] = undefined;
return true;
}
return false;
}
export function uncacheAll() {
filesystemCache = {};
}
export function readFileSync(filesystem, filename, info) {
if (info.size <= 0) {
return Buffer.alloc(0);
}
if (info.unpacked) {
// it's an unpacked file, copy it.
const unpackedDir = `${filesystem.getRootPath()}.unpacked`;
return fs.readFileSync(ensureWithin(unpackedDir, filename));
}
// Node throws an exception when reading 0 bytes into a 0-size buffer,
// so we short-circuit the read in this case.
const fd = fs.openSync(filesystem.getRootPath(), 'r');
try {
const fileOffset = parseInt(info.offset);
if (Number.isNaN(fileOffset) || fileOffset < 0 || !Number.isSafeInteger(fileOffset)) {
throw new Error(`Invalid file offset in archive header: ${info.offset}`);
}
const offset = 8 + filesystem.getHeaderSize() + fileOffset;
if (!Number.isSafeInteger(offset)) {
throw new Error(`Computed offset exceeds safe integer range`);
}
const archiveSize = fs.fstatSync(fd).size;
if (offset < 0 || offset + info.size > archiveSize) {
throw new Error(`File entry extends beyond archive boundary (offset=${offset}, size=${info.size}, archiveSize=${archiveSize})`);
}
const buffer = Buffer.alloc(info.size);
fs.readSync(fd, buffer, 0, info.size, offset);
return buffer;
}
finally {
fs.closeSync(fd);
}
}
export function readFileWithFd(fd, filesystem, filename, info) {
let buffer = Buffer.alloc(info.size);
if (info.size <= 0) {
return buffer;
}
if (info.unpacked) {
const unpackedDir = `${filesystem.getRootPath()}.unpacked`;
buffer = fs.readFileSync(ensureWithin(unpackedDir, filename));
}
else {
const offset = 8 + filesystem.getHeaderSize() + parseInt(info.offset);
fs.readSync(fd, buffer, 0, info.size, offset);
}
return buffer;
}
async function createFilesystemWriteStream(filesystem, dest) {
const headerPickle = Pickle.createEmpty();
headerPickle.writeString(JSON.stringify(filesystem.getHeader()));
const headerBuf = headerPickle.toBuffer();
const sizePickle = Pickle.createEmpty();
sizePickle.writeUInt32(headerBuf.length);
const sizeBuf = sizePickle.toBuffer();
const out = fs.createWriteStream(dest);
await new Promise((resolve, reject) => {
out.on('error', reject);
out.write(sizeBuf);
return out.write(headerBuf, () => resolve());
});
return out;
}
async function createSymlink(dest, filepath, link) {
// if symlink is within subdirectories, then we need to recreate dir structure
await fs.mkdirp(path.join(`${dest}.unpacked`, path.dirname(filepath)));
// create symlink within unpacked dir
await fs.symlink(link, path.join(`${dest}.unpacked`, filepath)).catch(async (error) => {
if (error.code === 'EPERM' && error.syscall === 'symlink') {
throw new Error('Could not create symlinks for unpacked assets. On Windows, consider activating Developer Mode to allow non-admin users to create symlinks by following the instructions at https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development.');
}
throw error;
});
}
//# sourceMappingURL=disk.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,62 @@
import { FileIntegrity } from './integrity.js';
import { CrawledFileType } from './crawlfs.js';
export type EntryMetadata = {
unpacked?: boolean;
};
export type FilesystemDirectoryEntry = {
files: Record<string, FilesystemEntry>;
} & EntryMetadata;
export type FilesystemFileEntry = {
unpacked: boolean;
executable: boolean;
offset: string;
size: number;
integrity: FileIntegrity;
} & EntryMetadata;
export type FilesystemLinkEntry = {
link: string;
} & EntryMetadata;
export type FilesystemEntry = FilesystemDirectoryEntry | FilesystemFileEntry | FilesystemLinkEntry;
export declare class Filesystem {
private src;
private header;
private headerSize;
private offset;
private contentOffsets;
constructor(src: string);
getRootPath(): string;
getHeader(): FilesystemEntry;
getHeaderSize(): number;
setHeader(header: FilesystemEntry, headerSize: number): void;
searchNodeFromDirectory(p: string): FilesystemEntry;
searchNodeFromPath(p: string): FilesystemEntry;
insertDirectory(p: string, shouldUnpack: boolean): Record<string, FilesystemEntry>;
insertFile(p: string, streamGenerator: () => NodeJS.ReadableStream, shouldUnpack: boolean, file: CrawledFileType, options?: {
transform?: (filePath: string) => NodeJS.ReadWriteStream | void;
/**
* Set when the file content comes from a stream rather than a file on
* disk at `p`. In that case `p` is only the destination path inside the
* archive, so integrity must be computed from the stream.
*/
fromStream?: boolean;
}): Promise<boolean>;
/**
* Fills in a file entry and reserves the region of the archive that holds its
* contents, growing the archive by `size` bytes. When a file with identical
* contents was already inserted, the entry instead points at the offset of those
* contents and the archive is left unchanged — the caller must then skip writing
* the contents.
*
* @returns whether the contents were already stored by an earlier file
*/
private storeFileEntry;
private insertFileAsync;
insertLink(p: string, shouldUnpack: boolean, parentPath?: string, symlink?: string, // /var/tmp => /private/var
src?: string): string;
private resolveLink;
listFiles(options?: {
isPack: boolean;
}): string[];
getNode(p: string, followLinks?: boolean, depth?: number, visited?: Set<string>): FilesystemEntry;
getFile(p: string, followLinks?: boolean, depth?: number, visited?: Set<string>): FilesystemEntry;
}

View file

@ -0,0 +1,256 @@
import os from 'node:os';
import path from 'node:path';
import stream from 'node:stream/promises';
import { getFileIntegrity, getFileIntegrityFromBuffer } from './integrity.js';
import { wrappedFs as fs } from './wrapped-fs.js';
const UINT32_MAX = 2 ** 32 - 1;
const SYMLINK_MAX_DEPTH = 40; // matches Linux SYMLOOP_MAX
// Files smaller than this use buffer-based integrity hashing (avoids stream overhead).
// Files larger than this use streaming to avoid holding large buffers in memory.
const BUFFER_HASH_THRESHOLD = 2 * 1024 * 1024; // 2MB
export class Filesystem {
src;
header;
headerSize;
offset;
// SHA-256 of contents already stored => the offset those contents live at
contentOffsets;
constructor(src) {
this.src = path.resolve(src);
this.header = { files: Object.create(null) };
this.headerSize = 0;
this.offset = BigInt(0);
this.contentOffsets = new Map();
}
getRootPath() {
return this.src;
}
getHeader() {
return this.header;
}
getHeaderSize() {
return this.headerSize;
}
setHeader(header, headerSize) {
this.header = header;
this.headerSize = headerSize;
}
searchNodeFromDirectory(p) {
let json = this.header;
const dirs = p.split(path.sep);
for (const dir of dirs) {
if (dir !== '.') {
if ('files' in json) {
if (!json.files[dir]) {
json.files[dir] = { files: Object.create(null) };
}
json = json.files[dir];
}
else {
throw new Error('Unexpected directory state while traversing: ' + p);
}
}
}
return json;
}
searchNodeFromPath(p) {
p = path.relative(this.src, p);
if (!p) {
return this.header;
}
const name = path.basename(p);
const node = this.searchNodeFromDirectory(path.dirname(p));
if (!node.files) {
node.files = Object.create(null);
}
if (!node.files[name]) {
node.files[name] = Object.create(null);
}
return node.files[name];
}
insertDirectory(p, shouldUnpack) {
const node = this.searchNodeFromPath(p);
if (shouldUnpack) {
node.unpacked = shouldUnpack;
}
node.files = node.files || Object.create(null);
return node.files;
}
insertFile(p, streamGenerator, shouldUnpack, file, options = {}) {
const dirNode = this.searchNodeFromPath(path.dirname(p));
const node = this.searchNodeFromPath(p);
if (shouldUnpack || dirNode.unpacked) {
node.size = file.stat.size;
node.unpacked = true;
// Unpacked files are copied out of the archive as standalone files, so every
// copy is written even when their contents are identical.
return getFileIntegrity(streamGenerator()).then((integrity) => {
node.integrity = integrity;
return false;
});
}
const transformed = options.transform && options.transform(p);
if (transformed) {
return this.insertFileAsync(p, streamGenerator, file, node, transformed);
}
const size = file.stat.size;
// JavaScript cannot precisely present integers >= UINT32_MAX.
if (size > UINT32_MAX) {
throw new Error(`${p}: file size can not be larger than 4.2GB`);
}
const executable = process.platform !== 'win32' && (file.stat.mode & 0o100) !== 0;
if (!options.fromStream && size <= BUFFER_HASH_THRESHOLD) {
// Fully synchronous fast path — no Promise, no stream, no microtask yield
try {
const fileBuffer = fs.readFileSync(p);
// Don't trust a buffer that doesn't match the size recorded in the header;
// hash the stream instead.
if (fileBuffer.length === size) {
const integrity = getFileIntegrityFromBuffer(fileBuffer);
const duplicate = this.storeFileEntry(node, size, executable, integrity);
if (!duplicate) {
file.cachedBuffer = fileBuffer;
}
return Promise.resolve(duplicate);
}
}
catch {
// Fall through to stream path
}
}
return getFileIntegrity(streamGenerator()).then((integrity) => this.storeFileEntry(node, size, executable, integrity));
}
/**
* Fills in a file entry and reserves the region of the archive that holds its
* contents, growing the archive by `size` bytes. When a file with identical
* contents was already inserted, the entry instead points at the offset of those
* contents and the archive is left unchanged — the caller must then skip writing
* the contents.
*
* @returns whether the contents were already stored by an earlier file
*/
storeFileEntry(node, size, executable, integrity) {
const sharedOffset = this.contentOffsets.get(integrity.hash);
node.size = size;
node.offset = sharedOffset ?? this.offset.toString();
if (executable) {
node.executable = true;
}
node.integrity = integrity;
if (sharedOffset !== undefined) {
return true;
}
this.contentOffsets.set(integrity.hash, node.offset);
this.offset += BigInt(size);
return false;
}
async insertFileAsync(p, streamGenerator, file, node, transformed) {
const tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'asar-'));
const tmpfile = path.join(tmpdir, path.basename(p));
const out = fs.createWriteStream(tmpfile);
await stream.pipeline(streamGenerator(), transformed, out);
file.transformed = {
path: tmpfile,
stat: await fs.lstat(tmpfile),
};
const size = file.transformed.stat.size;
if (size > UINT32_MAX) {
throw new Error(`${p}: file size can not be larger than 4.2GB`);
}
// Integrity must be computed over the transformed bytes that are actually
// stored in the archive, not the original (pre-transform) source bytes.
const integrity = await getFileIntegrity(fs.createReadStream(file.transformed.path));
const executable = process.platform !== 'win32' && (file.stat.mode & 0o100) !== 0;
const duplicate = this.storeFileEntry(node, size, executable, integrity);
if (duplicate) {
// Nothing will read these transformed bytes again, so don't leave them behind
file.transformed = undefined;
fs.rmSync(tmpdir, { recursive: true, force: true });
}
return duplicate;
}
insertLink(p, shouldUnpack, parentPath = fs.realpathSync(path.dirname(p)), symlink = fs.readlinkSync(p), // /var/tmp => /private/var
src = fs.realpathSync(this.src)) {
const link = this.resolveLink(src, parentPath, symlink);
if (path.isAbsolute(link) || path.normalize(link).startsWith('..')) {
throw new Error(`${p}: file "${link}" links out of the package`);
}
const node = this.searchNodeFromPath(p);
const dirNode = this.searchNodeFromPath(path.dirname(p));
if (shouldUnpack || dirNode.unpacked) {
node.unpacked = true;
}
node.link = link;
return link;
}
resolveLink(src, parentPath, symlink) {
// Use path.resolve (not path.join) so that an absolute symlink target is
// honored as-is instead of being concatenated onto parentPath. With join,
// an absolute target's leading separator is swallowed, producing a broken
// relative link for in-package targets and silently bypassing the
// out-of-package guard for targets outside the package. resolve handles
// both absolute and relative targets through a single code path.
const target = path.resolve(parentPath, symlink);
const link = path.relative(src, target);
return link;
}
listFiles(options) {
const files = [];
const fillFilesFromMetadata = function (basePath, metadata) {
if (!('files' in metadata)) {
return;
}
for (const [childPath, childMetadata] of Object.entries(metadata.files)) {
const fullPath = path.join(basePath, childPath);
const packState = 'unpacked' in childMetadata && childMetadata.unpacked ? 'unpack' : 'pack ';
files.push(options && options.isPack ? `${packState} : ${fullPath}` : fullPath);
fillFilesFromMetadata(fullPath, childMetadata);
}
};
fillFilesFromMetadata('/', this.header);
return files;
}
getNode(p, followLinks = true, depth = 0, visited = new Set()) {
const node = this.searchNodeFromDirectory(path.dirname(p));
const name = path.basename(p);
if ('link' in node && followLinks) {
const resolvedPath = path.join(node.link, name);
if (visited.has(resolvedPath)) {
throw new Error(`"${p}": circular symlink detected at "${resolvedPath}"`);
}
if (depth >= SYMLINK_MAX_DEPTH) {
throw new Error(`"${p}": too many levels of symbolic links (>${SYMLINK_MAX_DEPTH})`);
}
visited.add(resolvedPath);
return this.getNode(resolvedPath, followLinks, depth + 1, visited);
}
if (name) {
return node.files[name];
}
else {
return node;
}
}
getFile(p, followLinks = true, depth = 0, visited = new Set()) {
const info = this.getNode(p, followLinks, depth, visited);
if (!info) {
throw new Error(`"${p}" was not found in this archive`);
}
// if followLinks is false we don't resolve symlinks
if ('link' in info && followLinks) {
const link = info.link;
if (visited.has(link)) {
throw new Error(`"${p}": circular symlink detected at "${link}"`);
}
if (depth >= SYMLINK_MAX_DEPTH) {
throw new Error(`"${p}": too many levels of symbolic links (>${SYMLINK_MAX_DEPTH})`);
}
visited.add(link);
return this.getFile(link, followLinks, depth + 1, visited);
}
else {
return info;
}
}
}
//# sourceMappingURL=filesystem.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
export type FileIntegrity = {
algorithm: 'SHA256';
hash: string;
blockSize: number;
blocks: string[];
};
export declare function getFileIntegrity(inputFileStream: NodeJS.ReadableStream): Promise<FileIntegrity>;
export declare function getFileIntegrityFromBuffer(data: Buffer): FileIntegrity;

View file

@ -0,0 +1,58 @@
import crypto from 'node:crypto';
import stream from 'node:stream';
import streamPromises from 'node:stream/promises';
const ALGORITHM = 'SHA256';
// 4MB default block size
const BLOCK_SIZE = 4 * 1024 * 1024;
export async function getFileIntegrity(inputFileStream) {
const fileHash = crypto.createHash(ALGORITHM);
const blockHashes = [];
let blockHash = crypto.createHash(ALGORITHM);
let currentBlockSize = 0;
await streamPromises.pipeline(inputFileStream, new stream.PassThrough({
decodeStrings: false,
transform(_chunk, encoding, callback) {
fileHash.update(_chunk);
let offset = 0;
while (offset < _chunk.byteLength) {
const remaining = BLOCK_SIZE - currentBlockSize;
const end = Math.min(offset + remaining, _chunk.byteLength);
const slice = offset === 0 && end === _chunk.byteLength ? _chunk : _chunk.subarray(offset, end);
blockHash.update(slice);
currentBlockSize += end - offset;
if (currentBlockSize === BLOCK_SIZE) {
blockHashes.push(blockHash.digest('hex'));
blockHash = crypto.createHash(ALGORITHM);
currentBlockSize = 0;
}
offset = end;
}
callback();
},
flush(callback) {
if (currentBlockSize > 0 || blockHashes.length === 0) {
blockHashes.push(blockHash.digest('hex'));
}
callback();
},
}));
return {
algorithm: ALGORITHM,
hash: fileHash.digest('hex'),
blockSize: BLOCK_SIZE,
blocks: blockHashes,
};
}
export function getFileIntegrityFromBuffer(data) {
const hash = crypto.createHash(ALGORITHM).update(data).digest('hex');
const blocks = [];
for (let offset = 0; offset < data.length; offset += BLOCK_SIZE) {
const end = Math.min(offset + BLOCK_SIZE, data.length);
blocks.push(crypto.createHash(ALGORITHM).update(data.subarray(offset, end)).digest('hex'));
}
if (data.length === 0) {
blocks.push(crypto.createHash(ALGORITHM).update(data).digest('hex'));
}
return { algorithm: ALGORITHM, hash, blockSize: BLOCK_SIZE, blocks };
}
//# sourceMappingURL=integrity.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"integrity.js","sourceRoot":"","sources":["../src/integrity.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,cAAc,MAAM,sBAAsB,CAAC;AAElD,MAAM,SAAS,GAAG,QAAQ,CAAC;AAC3B,yBAAyB;AACzB,MAAM,UAAU,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AASnC,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,eAAsC;IAEtC,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IAE9C,MAAM,WAAW,GAAa,EAAE,CAAC;IACjC,IAAI,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IAC7C,IAAI,gBAAgB,GAAG,CAAC,CAAC;IAEzB,MAAM,cAAc,CAAC,QAAQ,CAC3B,eAAe,EACf,IAAI,MAAM,CAAC,WAAW,CAAC;QACrB,aAAa,EAAE,KAAK;QACpB,SAAS,CAAC,MAAc,EAAE,QAAQ,EAAE,QAAQ;YAC1C,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAExB,IAAI,MAAM,GAAG,CAAC,CAAC;YACf,OAAO,MAAM,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;gBAClC,MAAM,SAAS,GAAG,UAAU,GAAG,gBAAgB,CAAC;gBAChD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,SAAS,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;gBAC5D,MAAM,KAAK,GACT,MAAM,KAAK,CAAC,IAAI,GAAG,KAAK,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;gBACpF,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACxB,gBAAgB,IAAI,GAAG,GAAG,MAAM,CAAC;gBACjC,IAAI,gBAAgB,KAAK,UAAU,EAAE,CAAC;oBACpC,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;oBAC1C,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;oBACzC,gBAAgB,GAAG,CAAC,CAAC;gBACvB,CAAC;gBACD,MAAM,GAAG,GAAG,CAAC;YACf,CAAC;YACD,QAAQ,EAAE,CAAC;QACb,CAAC;QACD,KAAK,CAAC,QAAQ;YACZ,IAAI,gBAAgB,GAAG,CAAC,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACrD,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;YAC5C,CAAC;YACD,QAAQ,EAAE,CAAC;QACb,CAAC;KACF,CAAC,CACH,CAAC;IAEF,OAAO;QACL,SAAS,EAAE,SAAS;QACpB,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC;QAC5B,SAAS,EAAE,UAAU;QACrB,MAAM,EAAE,WAAW;KACpB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,IAAY;IACrD,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrE,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,MAAM,IAAI,UAAU,EAAE,CAAC;QAChE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;QACvD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC7F,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACvE,CAAC;IACD,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACvE,CAAC"}

View file

@ -0,0 +1,6 @@
/**
* Validates that a resolved child path is strictly within a container directory.
* Uses path.resolve (not realpath) so it works before paths exist on disk.
* Throws if the resolved path escapes the container via traversal sequences.
*/
export declare function ensureWithin(container: string, filePath: string): string;

View file

@ -0,0 +1,16 @@
import path from 'node:path';
/**
* Validates that a resolved child path is strictly within a container directory.
* Uses path.resolve (not realpath) so it works before paths exist on disk.
* Throws if the resolved path escapes the container via traversal sequences.
*/
export function ensureWithin(container, filePath) {
const resolvedContainer = path.resolve(container);
const resolvedPath = path.resolve(resolvedContainer, filePath);
if (!resolvedPath.startsWith(resolvedContainer + path.sep) &&
resolvedPath !== resolvedContainer) {
throw new Error(`Path "${filePath}" resolves to "${resolvedPath}" which is outside "${resolvedContainer}"`);
}
return resolvedPath;
}
//# sourceMappingURL=path-validation.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"path-validation.js","sourceRoot":"","sources":["../src/path-validation.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,SAAiB,EAAE,QAAgB;IAC9D,MAAM,iBAAiB,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAClD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CAAC;IAC/D,IACE,CAAC,YAAY,CAAC,UAAU,CAAC,iBAAiB,GAAG,IAAI,CAAC,GAAG,CAAC;QACtD,YAAY,KAAK,iBAAiB,EAClC,CAAC;QACD,MAAM,IAAI,KAAK,CACb,SAAS,QAAQ,kBAAkB,YAAY,uBAAuB,iBAAiB,GAAG,CAC3F,CAAC;IACJ,CAAC;IACD,OAAO,YAAY,CAAC;AACtB,CAAC"}

View file

@ -0,0 +1,46 @@
declare class PickleIterator {
private payload;
private payloadOffset;
private readIndex;
private endIndex;
constructor(pickle: Pickle);
readBool(): boolean;
readInt(): number;
readUInt32(): number;
readInt64(): bigint;
readUInt64(): bigint;
readFloat(): number;
readDouble(): number;
readString(): string;
readBytes(length: number): Buffer;
readBytes<R, F extends (...args: any[]) => R>(length: number, method: F): R;
getReadPayloadOffsetAndAdvance(length: number): number;
advance(size: number): void;
}
export declare class Pickle {
private header;
private headerSize;
private capacityAfterHeader;
private writeOffset;
private constructor();
static createEmpty(): Pickle;
static createFromBuffer(buffer: Buffer): Pickle;
getHeader(): Buffer<ArrayBufferLike>;
getHeaderSize(): number;
createIterator(): PickleIterator;
toBuffer(): Buffer<ArrayBuffer>;
writeBool(value: boolean): boolean;
writeInt(value: number): boolean;
writeUInt32(value: number): boolean;
writeInt64(value: number): boolean;
writeUInt64(value: number): boolean;
writeFloat(value: number): boolean;
writeDouble(value: number): boolean;
writeString(value: string): boolean;
setPayloadSize(payloadSize: number): number;
getPayloadSize(): number;
writeBytes(data: string, length: number, method?: undefined): boolean;
writeBytes(data: number | bigint, length: number, method: Function): boolean;
resize(newCapacity: number): void;
}
export {};

View file

@ -0,0 +1,205 @@
// sizeof(T).
const SIZE_INT32 = 4;
const SIZE_UINT32 = 4;
const SIZE_INT64 = 8;
const SIZE_UINT64 = 8;
const SIZE_FLOAT = 4;
const SIZE_DOUBLE = 8;
// The allocation granularity of the payload.
const PAYLOAD_UNIT = 64;
// Largest JS number.
const CAPACITY_READ_ONLY = 9007199254740992;
// Aligns 'i' by rounding it up to the next multiple of 'alignment'.
const alignInt = function (i, alignment) {
return i + ((alignment - (i % alignment)) % alignment);
};
// PickleIterator reads data from a Pickle. The Pickle object must remain valid
// while the PickleIterator object is in use.
class PickleIterator {
payload;
payloadOffset;
readIndex;
endIndex;
constructor(pickle) {
this.payload = pickle.getHeader();
this.payloadOffset = pickle.getHeaderSize();
this.readIndex = 0;
this.endIndex = pickle.getPayloadSize();
}
readBool() {
return this.readInt() !== 0;
}
readInt() {
return this.readBytes(SIZE_INT32, Buffer.prototype.readInt32LE);
}
readUInt32() {
return this.readBytes(SIZE_UINT32, Buffer.prototype.readUInt32LE);
}
readInt64() {
return this.readBytes(SIZE_INT64, Buffer.prototype.readBigInt64LE);
}
readUInt64() {
return this.readBytes(SIZE_UINT64, Buffer.prototype.readBigUInt64LE);
}
readFloat() {
return this.readBytes(SIZE_FLOAT, Buffer.prototype.readFloatLE);
}
readDouble() {
return this.readBytes(SIZE_DOUBLE, Buffer.prototype.readDoubleLE);
}
readString() {
return this.readBytes(this.readInt()).toString();
}
readBytes(length, method) {
const readPayloadOffset = this.getReadPayloadOffsetAndAdvance(length);
if (method != null) {
return method.call(this.payload, readPayloadOffset, length);
}
else {
return this.payload.slice(readPayloadOffset, readPayloadOffset + length);
}
}
getReadPayloadOffsetAndAdvance(length) {
if (length > this.endIndex - this.readIndex) {
this.readIndex = this.endIndex;
throw new Error('Failed to read data with length of ' + length);
}
const readPayloadOffset = this.payloadOffset + this.readIndex;
this.advance(length);
return readPayloadOffset;
}
advance(size) {
const alignedSize = alignInt(size, SIZE_UINT32);
if (this.endIndex - this.readIndex < alignedSize) {
this.readIndex = this.endIndex;
}
else {
this.readIndex += alignedSize;
}
}
}
// This class provides facilities for basic binary value packing and unpacking.
//
// The Pickle class supports appending primitive values (ints, strings, etc.)
// to a pickle instance. The Pickle instance grows its internal memory buffer
// dynamically to hold the sequence of primitive values. The internal memory
// buffer is exposed as the "data" of the Pickle. This "data" can be passed
// to a Pickle object to initialize it for reading.
//
// When reading from a Pickle object, it is important for the consumer to know
// what value types to read and in what order to read them as the Pickle does
// not keep track of the type of data written to it.
//
// The Pickle's data has a header which contains the size of the Pickle's
// payload. It can optionally support additional space in the header. That
// space is controlled by the header_size parameter passed to the Pickle
// constructor.
export class Pickle {
header;
headerSize;
capacityAfterHeader;
writeOffset;
constructor(buffer) {
if (buffer) {
this.header = buffer;
this.headerSize = buffer.length - this.getPayloadSize();
this.capacityAfterHeader = CAPACITY_READ_ONLY;
this.writeOffset = 0;
if (this.headerSize > buffer.length) {
this.headerSize = 0;
}
if (this.headerSize !== alignInt(this.headerSize, SIZE_UINT32)) {
this.headerSize = 0;
}
if (this.headerSize === 0) {
this.header = Buffer.alloc(0);
}
}
else {
this.header = Buffer.alloc(0);
this.headerSize = SIZE_UINT32;
this.capacityAfterHeader = 0;
this.writeOffset = 0;
this.resize(PAYLOAD_UNIT);
this.setPayloadSize(0);
}
}
static createEmpty() {
return new Pickle();
}
static createFromBuffer(buffer) {
return new Pickle(buffer);
}
getHeader() {
return this.header;
}
getHeaderSize() {
return this.headerSize;
}
createIterator() {
return new PickleIterator(this);
}
toBuffer() {
return this.header.slice(0, this.headerSize + this.getPayloadSize());
}
writeBool(value) {
return this.writeInt(value ? 1 : 0);
}
writeInt(value) {
return this.writeBytes(value, SIZE_INT32, Buffer.prototype.writeInt32LE);
}
writeUInt32(value) {
return this.writeBytes(value, SIZE_UINT32, Buffer.prototype.writeUInt32LE);
}
writeInt64(value) {
return this.writeBytes(BigInt(value), SIZE_INT64, Buffer.prototype.writeBigInt64LE);
}
writeUInt64(value) {
return this.writeBytes(BigInt(value), SIZE_UINT64, Buffer.prototype.writeBigUInt64LE);
}
writeFloat(value) {
return this.writeBytes(value, SIZE_FLOAT, Buffer.prototype.writeFloatLE);
}
writeDouble(value) {
return this.writeBytes(value, SIZE_DOUBLE, Buffer.prototype.writeDoubleLE);
}
writeString(value) {
const length = Buffer.byteLength(value, 'utf8');
if (!this.writeInt(length)) {
return false;
}
return this.writeBytes(value, length);
}
setPayloadSize(payloadSize) {
return this.header.writeUInt32LE(payloadSize, 0);
}
getPayloadSize() {
return this.header.readUInt32LE(0);
}
writeBytes(data, length, method) {
const dataLength = alignInt(length, SIZE_UINT32);
const newSize = this.writeOffset + dataLength;
if (newSize > this.capacityAfterHeader) {
this.resize(Math.max(this.capacityAfterHeader * 2, newSize));
}
if (method) {
method.call(this.header, data, this.headerSize + this.writeOffset);
}
else {
this.header.write(data, this.headerSize + this.writeOffset, length);
}
const endOffset = this.headerSize + this.writeOffset + length;
this.header.fill(0, endOffset, endOffset + dataLength - length);
this.setPayloadSize(newSize);
this.writeOffset = newSize;
return true;
}
resize(newCapacity) {
newCapacity = alignInt(newCapacity, PAYLOAD_UNIT);
const newHeader = Buffer.alloc(this.headerSize + newCapacity);
this.header.copy(newHeader, 0, 0, this.headerSize + this.writeOffset);
this.header = newHeader;
this.capacityAfterHeader = newCapacity;
}
}
//# sourceMappingURL=pickle.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
type AsarFS = typeof import('fs') & {
mkdirp(dir: string): Promise<void>;
mkdirpSync(dir: string): void;
lstat: (typeof import('fs'))['promises']['lstat'];
mkdtemp: (typeof import('fs'))['promises']['mkdtemp'];
readFile: (typeof import('fs'))['promises']['readFile'];
stat: (typeof import('fs'))['promises']['stat'];
writeFile: (typeof import('fs'))['promises']['writeFile'];
symlink: (typeof import('fs'))['promises']['symlink'];
readlink: (typeof import('fs'))['promises']['readlink'];
};
export declare const wrappedFs: AsarFS;
export {};

View file

@ -0,0 +1,23 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const fs = 'electron' in process.versions ? require('original-fs') : require('node:fs');
const promisifiedMethods = [
'lstat',
'mkdtemp',
'readFile',
'stat',
'writeFile',
'symlink',
'readlink',
];
export const wrappedFs = Object.keys(fs).reduce((accum, method) => {
return {
...accum,
[method]: promisifiedMethods.includes(method) ? fs.promises[method] : fs[method],
};
}, {
// To make it more like fs-extra
mkdirp: (dir) => fs.promises.mkdir(dir, { recursive: true }),
mkdirpSync: (dir) => fs.mkdirSync(dir, { recursive: true }),
});
//# sourceMappingURL=wrapped-fs.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"wrapped-fs.js","sourceRoot":"","sources":["../src/wrapped-fs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAE/C,MAAM,EAAE,GAAG,UAAU,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAExF,MAAM,kBAAkB,GAAG;IACzB,OAAO;IACP,SAAS;IACT,UAAU;IACV,MAAM;IACN,WAAW;IACX,SAAS;IACT,UAAU;CACX,CAAC;AAcF,MAAM,CAAC,MAAM,SAAS,GAAW,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CACrD,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;IAChB,OAAO;QACL,GAAG,KAAK;QACR,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;KACjF,CAAC;AACJ,CAAC,EACD;IACE,gCAAgC;IAChC,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC5D,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;CAClD,CACZ,CAAC"}

View file

@ -0,0 +1,78 @@
{
"name": "@electron/asar",
"description": "Creating Electron app packages",
"version": "4.3.1",
"type": "module",
"exports": "./lib/asar.js",
"types": "./lib/asar.d.ts",
"bin": "./bin/asar.mjs",
"files": [
"bin",
"lib"
],
"engines": {
"node": ">=22.12.0"
},
"license": "MIT",
"homepage": "https://github.com/electron/asar",
"repository": {
"type": "git",
"url": "git+https://github.com/electron/asar.git"
},
"bugs": {
"url": "https://github.com/electron/asar/issues"
},
"publishConfig": {
"provenance": true
},
"scripts": {
"build": "tsc",
"build:docs": "typedoc src/asar.ts",
"test": "yarn lint && yarn vitest run && xvfb-maybe electron node_modules/vitest/vitest.mjs run",
"test:typecheck": "cd test && tsc",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"lint": "oxfmt --check . && oxlint",
"lint:fix": "oxfmt --write . && oxlint --fix",
"benchmark": "yarn build && tsx benchmark/run.ts",
"benchmark:quick": "yarn build && tsx benchmark/run.ts small",
"prepack": "yarn build",
"prepare": "husky"
},
"dependencies": {
"glob": "^13.0.2",
"minimatch": "^10.0.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.12.0",
"electron": "^43.0.0",
"husky": "^9.1.7",
"lint-staged": "^17.4.1",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"tsx": "^4.21.0",
"typedoc": "~0.28.0",
"typescript": "^6.0.2",
"vite": "^8.0.16",
"vitest": "^5.0.1",
"xvfb-maybe": "^0.2.1"
},
"resolutions": {
"fsevents": "npm:@electron/fsevents@2.3.3-fork"
},
"packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f",
"dependenciesMeta": {
"electron": {
"built": true
}
},
"lint-staged": {
"*.{json,md}": "oxfmt --write",
"*.{js,ts,cjs,mjs,cts,mts}": [
"oxlint --fix",
"oxfmt --write"
]
}
}

View file

@ -0,0 +1,23 @@
(MIT)
Original code Copyright Julian Gruber <julian@juliangruber.com>
Port to TypeScript Copyright Isaac Z. Schlueter <i@izs.me>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,57 @@
# balanced-match
Match balanced string pairs, like `{` and `}` or `<b>` and
`</b>`. Supports regular expressions as well!
## Example
Get the first matching pair of braces:
```js
import { balanced } from 'balanced-match'
console.log(balanced('{', '}', 'pre{in{nested}}post'))
console.log(balanced('{', '}', 'pre{first}between{second}post'))
console.log(
balanced(/\s+\{\s+/, /\s+\}\s+/, 'pre { in{nest} } post'),
)
```
The matches are:
```bash
$ node example.js
{ start: 3, end: 14, pre: 'pre', body: 'in{nested}', post: 'post' }
{ start: 3,
end: 9,
pre: 'pre',
body: 'first',
post: 'between{second}post' }
{ start: 3, end: 17, pre: 'pre', body: 'in{nest}', post: 'post' }
```
## API
### const m = balanced(a, b, str)
For the first non-nested matching pair of `a` and `b` in `str`, return an
object with those keys:
- **start** the index of the first match of `a`
- **end** the index of the matching `b`
- **pre** the preamble, `a` and `b` not included
- **body** the match, `a` and `b` not included
- **post** the postscript, `a` and `b` not included
If there's no match, `undefined` will be returned.
If the `str` contains more `a` than `b` / there are unmatched pairs, the first match that was closed will be used. For example, `{{a}` will match `['{', 'a', '']` and `{a}}` will match `['', 'a', '}']`.
### const r = balanced.range(a, b, str)
For the first non-nested matching pair of `a` and `b` in `str`, return an
array with indexes: `[ <a index>, <b index> ]`.
If there's no match, `undefined` will be returned.
If the `str` contains more `a` than `b` / there are unmatched pairs, the first match that was closed will be used. For example, `{{a}` will match `[ 1, 3 ]` and `{a}}` will match `[0, 2]`.

View file

@ -0,0 +1,68 @@
{
"name": "balanced-match",
"description": "Match balanced character pairs, like \"{\" and \"}\"",
"version": "4.0.4",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git://github.com/juliangruber/balanced-match.git"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"type": "module",
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"prepare": "tshy",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write .",
"benchmark": "node benchmark/index.js",
"typedoc": "typedoc --tsconfig .tshy/esm.json ./src/*.ts"
},
"devDependencies": {
"@types/brace-expansion": "^1.1.2",
"@types/node": "^25.2.1",
"mkdirp": "^3.0.1",
"prettier": "^3.3.2",
"tap": "^21.6.2",
"tshy": "^3.0.2",
"typedoc": "^0.28.5"
},
"keywords": [
"match",
"regexp",
"test",
"balanced",
"parse"
],
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
},
"tshy": {
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
}
},
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js"
}

View file

@ -0,0 +1,23 @@
MIT License
Copyright Julian Gruber <julian@juliangruber.com>
TypeScript port Copyright Isaac Z. Schlueter <i@izs.me>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,94 @@
# brace-expansion
[Brace expansion](https://www.gnu.org/software/bash/manual/html_node/Brace-Expansion.html),
as known from sh/bash, in JavaScript.
[![CI](https://github.com/juliangruber/brace-expansion/actions/workflows/ci.yml/badge.svg)](https://github.com/juliangruber/brace-expansion/actions/workflows/ci.yml)
[![downloads](https://img.shields.io/npm/dm/brace-expansion.svg)](https://www.npmjs.org/package/brace-expansion)
## Example
```js
import { expand } from 'brace-expansion'
expand('file-{a,b,c}.jpg')
// => ['file-a.jpg', 'file-b.jpg', 'file-c.jpg']
expand('-v{,,}')
// => ['-v', '-v', '-v']
expand('file{0..2}.jpg')
// => ['file0.jpg', 'file1.jpg', 'file2.jpg']
expand('file-{a..c}.jpg')
// => ['file-a.jpg', 'file-b.jpg', 'file-c.jpg']
expand('file{2..0}.jpg')
// => ['file2.jpg', 'file1.jpg', 'file0.jpg']
expand('file{0..4..2}.jpg')
// => ['file0.jpg', 'file2.jpg', 'file4.jpg']
expand('file-{a..e..2}.jpg')
// => ['file-a.jpg', 'file-c.jpg', 'file-e.jpg']
expand('file{00..10..5}.jpg')
// => ['file00.jpg', 'file05.jpg', 'file10.jpg']
expand('{{A..C},{a..c}}')
// => ['A', 'B', 'C', 'a', 'b', 'c']
expand('ppp{,config,oe{,conf}}')
// => ['ppp', 'pppconfig', 'pppoe', 'pppoeconf']
```
## API
```js
import { expand } from 'brace-expansion'
```
### const expanded = expand(str, [options])
Return an array of all possible and valid expansions of `str`. If
none are found, `[str]` is returned.
The `options` object can provide a `max` value to cap the number
of expansions allowed. This is limited to `100_000` by default,
to prevent DoS attacks.
```js
const expansions = expand('{1..100}'.repeat(5), {
max: 100,
})
// expansions.length will be 100, not 100^5
```
Valid expansions are:
```js
;/^(.*,)+(.+)?$/
// {a,b,...}
```
A comma separated list of options, like `{a,b}` or `{a,{b,c}}` or `{,a,}`.
```js
;/^-?\d+\.\.-?\d+(\.\.-?\d+)?$/
// {x..y[..incr]}
```
A numeric sequence from `x` to `y` inclusive, with optional increment.
If `x` or `y` start with a leading `0`, all the numbers will be padded
to have equal length. Negative numbers and backwards iteration work too.
```js
;/^-?\d+\.\.-?\d+(\.\.-?\d+)?$/
// {x..y[..incr]}
```
An alphabetic sequence from `x` to `y` inclusive, with optional increment.
`x` and `y` must be exactly one character, and if given, `incr` must be a
number.
For compatibility reasons, the string `${` is not eligible for brace expansion.

View file

@ -0,0 +1,64 @@
{
"name": "brace-expansion",
"description": "Brace expansion as known from sh/bash",
"version": "5.0.6",
"files": [
"dist"
],
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"type": "module",
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"prepare": "tshy",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write .",
"benchmark": "node benchmark/index.js",
"typedoc": "typedoc --tsconfig .tshy/esm.json ./src/*.ts"
},
"devDependencies": {
"@types/brace-expansion": "^1.1.2",
"@types/node": "^25.2.1",
"mkdirp": "^3.0.1",
"prettier": "^3.3.2",
"tap": "^21.6.2",
"tshy": "^3.0.2",
"typedoc": "^0.28.5"
},
"dependencies": {
"balanced-match": "^4.0.2"
},
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
},
"tshy": {
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
}
},
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js",
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/juliangruber/brace-expansion.git"
}
}

63
patcher/vendor/asar/node_modules/glob/LICENSE.md generated vendored Normal file
View file

@ -0,0 +1,63 @@
All packages under `src/` are licensed according to the terms in
their respective `LICENSE` or `LICENSE.md` files.
The remainder of this project is licensed under the Blue Oak
Model License, as follows:
-----
# Blue Oak Model License
Version 1.0.0
## Purpose
This license gives everyone as much permission to work with
this software as possible, while protecting contributors
from liability.
## Acceptance
In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***

1203
patcher/vendor/asar/node_modules/glob/README.md generated vendored Normal file

File diff suppressed because it is too large Load diff

98
patcher/vendor/asar/node_modules/glob/package.json generated vendored Normal file
View file

@ -0,0 +1,98 @@
{
"author": "Isaac Z. Schlueter <i@izs.me> (https://blog.izs.me/)",
"name": "glob",
"description": "the most correct and second fastest glob implementation in JavaScript",
"version": "13.0.6",
"type": "module",
"tshy": {
"exports": {
"./package.json": "./package.json",
"./raw": "./src/index.ts",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.min.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.min.js"
}
}
}
},
"main": "./dist/commonjs/index.min.js",
"module": "./dist/esm/index.min.js",
"types": "./dist/commonjs/index.d.ts",
"exports": {
"./package.json": "./package.json",
"./raw": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
},
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.min.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.min.js"
}
}
},
"repository": {
"type": "git",
"url": "git@github.com:isaacs/node-glob.git"
},
"files": [
"dist"
],
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "npm run benchclean; git push origin --follow-tags",
"prepare": "tshy && bash scripts/build.sh",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write . --log-level warn",
"typedoc": "typedoc",
"profclean": "rm -f v8.log profile.txt",
"test-regen": "npm run profclean && TEST_REGEN=1 node --no-warnings --loader ts-node/esm test/00-setup.ts",
"prebench": "npm run prepare",
"bench": "bash benchmark.sh",
"preprof": "npm run prepare",
"prof": "bash prof.sh",
"benchclean": "node benchclean.cjs"
},
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"devDependencies": {
"@types/node": "^25.3.0",
"esbuild": "^0.27.3",
"memfs": "^4.50.0",
"mkdirp": "^3.0.1",
"prettier": "^3.6.2",
"rimraf": "^6.1.3",
"tap": "^21.6.1",
"tshy": "^3.3.2",
"typedoc": "^0.28.17"
},
"license": "BlueOak-1.0.0",
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"engines": {
"node": "18 || 20 || >=22"
}
}

55
patcher/vendor/asar/node_modules/lru-cache/LICENSE.md generated vendored Normal file
View file

@ -0,0 +1,55 @@
# Blue Oak Model License
Version 1.0.0
## Purpose
This license gives everyone as much permission to work with
this software as possible, while protecting contributors
from liability.
## Acceptance
In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***

469
patcher/vendor/asar/node_modules/lru-cache/README.md generated vendored Normal file
View file

@ -0,0 +1,469 @@
# lru-cache
A cache object that deletes the least-recently-used items.
Specify a max number of the most recently used items that you
want to keep, and this cache will keep that many of the most
recently accessed items.
This is not primarily a TTL cache, and does not make strong TTL
guarantees. There is no preemptive pruning of expired items by
default, but you _may_ set a TTL on the cache or on a single
`set`. If you do so, it will treat expired items as missing, and
delete them when fetched. If you are more interested in TTL
caching than LRU caching, check out
[@isaacs/ttlcache](http://npm.im/@isaacs/ttlcache).
As of version 7, this is one of the most performant LRU
implementations available in JavaScript, and supports a wide
diversity of use cases. However, note that using some of the
features will necessarily impact performance, by causing the
cache to have to do more work. See the "Performance" section
below.
## Installation
```bash
npm install lru-cache --save
```
## Usage
```js
// hybrid module, either works
import { LRUCache } from 'lru-cache'
// or:
const { LRUCache } = require('lru-cache')
// or in minified form for web browsers:
import { LRUCache } from 'http://unpkg.com/lru-cache@9/dist/mjs/index.min.mjs'
// At least one of 'max', 'ttl', or 'maxSize' is required, to prevent
// unsafe unbounded storage.
//
// In most cases, it's best to specify a max for performance, so all
// the required memory allocation is done up-front.
//
// All the other options are optional, see the sections below for
// documentation on what each one does. Most of them can be
// overridden for specific items in get()/set()
const options = {
max: 500,
// for use with tracking overall storage size
maxSize: 5000,
sizeCalculation: (value, key) => {
return 1
},
// for use when you need to clean up something when objects
// are evicted from the cache
dispose: (value, key, reason) => {
freeFromMemoryOrWhatever(value)
},
// for use when you need to know that an item is being inserted
// note that this does NOT allow you to prevent the insertion,
// it just allows you to know about it.
onInsert: (value, key, reason) => {
logInsertionOrWhatever(key, value)
},
// how long to live in ms
ttl: 1000 * 60 * 5,
// return stale items before removing from cache?
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
// async method to use for cache.fetch(), for
// stale-while-revalidate type of behavior
fetchMethod: async (key, staleValue, { options, signal, context }) => {},
}
const cache = new LRUCache(options)
cache.set('key', 'value')
cache.get('key') // "value"
// non-string keys ARE fully supported
// but note that it must be THE SAME object, not
// just a JSON-equivalent object.
var someObject = { a: 1 }
cache.set(someObject, 'a value')
// Object keys are not toString()-ed
cache.set('[object Object]', 'a different value')
assert.equal(cache.get(someObject), 'a value')
// A similar object with same keys/values won't work,
// because it's a different object identity
assert.equal(cache.get({ a: 1 }), undefined)
cache.clear() // empty the cache
```
If you put more stuff in the cache, then less recently used items
will fall out. That's what an LRU cache is.
For full description of the API and all options, please see [the
LRUCache typedocs](https://isaacs.github.io/node-lru-cache/)
## Storage Bounds Safety
This implementation aims to be as flexible as possible, within
the limits of safe memory consumption and optimal performance.
At initial object creation, storage is allocated for `max` items.
If `max` is set to zero, then some performance is lost, and item
count is unbounded. Either `maxSize` or `ttl` _must_ be set if
`max` is not specified.
If `maxSize` is set, then this creates a safe limit on the
maximum storage consumed, but without the performance benefits of
pre-allocation. When `maxSize` is set, every item _must_ provide
a size, either via the `sizeCalculation` method provided to the
constructor, or via a `size` or `sizeCalculation` option provided
to `cache.set()`. The size of every item _must_ be a positive
integer.
If neither `max` nor `maxSize` are set, then `ttl` tracking must
be enabled. Note that, even when tracking item `ttl`, items are
_not_ preemptively deleted when they become stale, unless
`ttlAutopurge` is enabled. Instead, they are only purged the
next time the key is requested. Thus, if `ttlAutopurge`, `max`,
and `maxSize` are all not set, then the cache will potentially
grow unbounded.
In this case, a warning is printed to standard error. Future
versions may require the use of `ttlAutopurge` if `max` and
`maxSize` are not specified.
If you truly wish to use a cache that is bound _only_ by TTL
expiration, consider using a `Map` object, and calling
`setTimeout` to delete entries when they expire. It will perform
much better than an LRU cache.
Here is an implementation you may use, under the same
[license](./LICENSE) as this package:
```js
// a storage-unbounded ttl cache that is not an lru-cache
const cache = {
data: new Map(),
timers: new Map(),
set: (k, v, ttl) => {
if (cache.timers.has(k)) {
clearTimeout(cache.timers.get(k))
}
cache.timers.set(
k,
setTimeout(() => cache.delete(k), ttl),
)
cache.data.set(k, v)
},
get: k => cache.data.get(k),
has: k => cache.data.has(k),
delete: k => {
if (cache.timers.has(k)) {
clearTimeout(cache.timers.get(k))
}
cache.timers.delete(k)
return cache.data.delete(k)
},
clear: () => {
cache.data.clear()
for (const v of cache.timers.values()) {
clearTimeout(v)
}
cache.timers.clear()
},
}
```
If that isn't to your liking, check out
[@isaacs/ttlcache](http://npm.im/@isaacs/ttlcache).
## Storing Undefined Values
This cache never stores undefined values, as `undefined` is used
internally in a few places to indicate that a key is not in the
cache.
You may call `cache.set(key, undefined)`, but this is just
an alias for `cache.delete(key)`. Note that this has the effect
that `cache.has(key)` will return _false_ after setting it to
undefined.
```js
cache.set(myKey, undefined)
cache.has(myKey) // false!
```
If you need to track `undefined` values, and still note that the
key is in the cache, an easy workaround is to use a sigil object
of your own.
```js
import { LRUCache } from 'lru-cache'
const undefinedValue = Symbol('undefined')
const cache = new LRUCache(...)
const mySet = (key, value) =>
cache.set(key, value === undefined ? undefinedValue : value)
const myGet = (key, value) => {
const v = cache.get(key)
return v === undefinedValue ? undefined : v
}
```
## Tracing and Observability
Most methods can accept a `status` option, which is an
[`LRUCache.Status`](https://isaacs.github.io/node-lru-cache/interfaces/LRUCache.LRUCache.Status.html)
object that will be decorated along the operation with
indications about what was done and why.
Additionally, this library is instrumented using the
[`node:diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html)
module on Node and other platforms that support it. In order to
get diagnostics metrics, listen on the
`channel('lru-cache:metrics')`. To get Tracing Channel traces,
subscribe to the `tracingChannel('lru-cache')`. The
[`LRUCache.Status`](https://isaacs.github.io/node-lru-cache/interfaces/LRUCache.LRUCache.Status.html)
objects will be provided as the message context to those channel
listeners.
For example, you could do the following to get comprehensive
information about every LRUCache instance in your application:
```ts
import { tracingChannel, subscribe } from 'node:diagnostics_channel'
subscribe('lru-cache:metrics', (message, name) => {
// name will always be 'lru-cache:metrics'
// message will be the LRUCache.Status object for whatever
// synchronous operation was performed.
console.error('LRUCache Metrics', message)
})
tracingChannel('lru-cache').subscribe({
start: status => {
// a traced operation is starting
},
asyncStart: status => {
// an async traced operation is starting
},
asyncEnd: status => {
// an async traced operation is ending
}
error: status => {
// a traced operation failed
},
end: status => {
// a traced operation is complete
},
})
```
The async `cache.fetch()` and `cache.forceFetch` methods are
covered by `tracingChannels`. All the other operations are
covered by the `lru-cache:metrics` channel, because they are
strictly synchronous, and thus don't have an asynchronous
lifecycle to track.
Note that using `status` objects or using
`node:diagnostics_channel` listeners _will_ impose a modest
performance penalty. Creating data objects is not ever free; do
not believe anyone who tells you otherwise. But it is as small as
possible.
### Platform Compatibility Caveat
Not all platforms support the `node:diagnostics_channel` module.
Currently, this is only available in Node, Bun, and Deno, and
some edge computing platforms that provide a Node compatibility
layer.
To work around this, if you are loading in a non-Node
environment, the package.json exports will direct your module
loader to pull in a version that starts out with a dummy
implementation, then does a conditional dynamic `import` of the
`node:diagnostics_channel` module, and then swaps out those
dummy objects with the real thing if it succeeds. This means that
cache metrics and tracing channels started in the first load-time
tick of your application will _not_ be covered, except in
environments that load using the `require` import
condition, or both the `node` and `esm` import conditions
together.
Top-level await _could_ be used to remove this caveat, but that
feature is dead on arrival, unfortunately. See
[#397](https://github.com/isaacs/node-lru-cache/issues/397) and
[#398](https://github.com/isaacs/node-lru-cache/issues/398) for
more details.
## Performance
As of April 2026, version 11 of this library is one of the most
performant LRU cache implementations in JavaScript.
Benchmarks can be extremely difficult to get right. In
particular, the performance of set/get/delete operations on
objects will vary _wildly_ depending on the type of key used. V8
is highly optimized for objects with keys that are short strings,
especially integer numeric strings. Thus any benchmark which
tests _solely_ using numbers as keys will tend to find that an
object-based approach performs the best.
Note that coercing _anything_ to strings to use as object keys is
unsafe, unless you can be 100% certain that no other type of
value will be used. For example:
```js
const myCache = {}
const set = (k, v) => (myCache[k] = v)
const get = k => myCache[k]
set({}, 'please hang onto this for me')
set('[object Object]', 'oopsie')
```
Also beware of "Just So" stories regarding performance. Garbage
collection of large (especially: deep) object graphs can be
incredibly costly, with several "tipping points" where it
increases exponentially. As a result, putting that off until
later can make it much worse, and less predictable. If a library
performs well, but only in a scenario where the object graph is
kept shallow, then that won't help you if you are using large
objects as keys.
In general, when attempting to use a library to improve
performance (such as a cache like this one), it's best to choose
an option that will perform well in the sorts of scenarios where
you'll actually use it.
This library is optimized for repeated gets and minimizing
eviction time, since that is the expected need of a LRU. Set
operations are somewhat slower on average than a few other
options, in part because of that optimization. It is assumed
that you'll be caching some costly operation, ideally as rarely
as possible, so optimizing set over get would be unwise.
If performance matters to you:
1. If it's at all possible to use small integer values as keys,
and you can guarantee that no other types of values will be
used as keys, then do that, and use a cache such as
[lru-fast](https://npmjs.com/package/lru-fast), or
[mnemonist's
LRUCache](https://yomguithereal.github.io/mnemonist/lru-cache)
which uses an Object as its data store.
2. Failing that, if you can use short non-numeric strings (ie,
less than 256 characters) as your keys, and you do not need
any of the other features of this library, use [mnemonist's
LRUCache](https://yomguithereal.github.io/mnemonist/lru-cache).
3. If the types of your keys will be anything else, especially
long strings, strings that look like floats, objects, or some
mix of types, or if you aren't sure, then this library will
work well for you.
If you do not need the features that this library provides
(like asynchronous fetching, a variety of TTL staleness
options, and so on), then [mnemonist's
LRUMap](https://yomguithereal.github.io/mnemonist/lru-map) is
also a very good option, and just slightly faster than this
module (since it does considerably less).
4. Do not use a `dispose` function, size tracking, or especially
ttl behavior or observability features, unless absolutely
needed. These features are convenient, and necessary in some
use cases, and every attempt has been made to make the
performance impact minimal, but it isn't nothing.
## Testing
When writing tests that involve TTL-related functionality, note
that this module creates an internal reference to the global
`performance` or `Date` objects at import time. If you import it
statically at the top level, those references cannot be mocked or
overridden in your test environment.
To avoid this, dynamically import the package within your tests
so that the references are captured after your mocks are applied.
For example:
```ts
// ❌ Not recommended
import { LRUCache } from 'lru-cache'
// mocking timers, e.g. jest.useFakeTimers()
// ✅ Recommended for TTL tests
// mocking timers, e.g. jest.useFakeTimers()
const { LRUCache } = await import('lru-cache')
```
This ensures that your mocked timers or time sources are
respected when testing TTL behavior.
Additionally, you can pass in a `perf` option when creating your
LRUCache instance. This option accepts any object with a `now`
method that returns a number.
For example, this would be a very bare-bones time-mocking system
you could use in your tests, without any particular test
framework:
```ts
import { LRUCache } from 'lru-cache'
let myClockTime = 0
const cache = new LRUCache<string>({
max: 10,
ttl: 1000,
perf: {
now: () => myClockTime,
},
})
// run tests, updating myClockTime as needed
```
## Breaking Changes in Version 7
This library changed to a different algorithm and internal data
structure in version 7, yielding significantly better
performance, albeit with some subtle changes as a result.
If you were relying on the internals of LRUCache in version 6 or
before, it probably will not work in version 7 and above.
## Breaking Changes in Version 8
- The `fetchContext` option was renamed to `context`, and may no
longer be set on the cache instance itself.
- Rewritten in TypeScript, so pretty much all the types moved
around a lot.
- The AbortController/AbortSignal polyfill was removed. For this
reason, **Node version 16.14.0 or higher is now required**.
- Internal properties were moved to actual private class
properties.
- Keys and values must not be `null` or `undefined`.
- Minified export available at `'lru-cache/min'`, for both CJS
and MJS builds.
## Breaking Changes in Version 9
- Named export only, no default export.
- AbortController polyfill returned, albeit with a warning when
used.
## Breaking Changes in Version 10
- `cache.fetch()` return type is now `Promise<V | undefined>`
instead of `Promise<V | void>`. This is an irrelevant change
practically speaking, but can require changes for TypeScript
users.
For more info, see the [change log](CHANGELOG.md).

154
patcher/vendor/asar/node_modules/lru-cache/package.json generated vendored Normal file
View file

@ -0,0 +1,154 @@
{
"name": "lru-cache",
"description": "A cache object that deletes the least-recently-used items.",
"version": "11.3.6",
"author": "Isaac Z. Schlueter <i@izs.me>",
"keywords": [
"mru",
"lru",
"cache"
],
"sideEffects": false,
"scripts": {
"build": "npm run prepare",
"prepare": "tshy && bash scripts/build.sh",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"format": "prettier --write .",
"typedoc": "typedoc --tsconfig ./.tshy/esm.json ./src/*.ts",
"benchmark-results-typedoc": "bash scripts/benchmark-results-typedoc.sh",
"prebenchmark": "npm run prepare",
"benchmark": "make -C benchmark",
"preprofile": "npm run prepare",
"profile": "make -C benchmark profile",
"lint": "oxlint --fix src test",
"postsnap": "npm run lint",
"postlint": "npm run format"
},
"main": "./dist/commonjs/index.min.js",
"types": "./dist/commonjs/index.d.ts",
"tshy": {
"esmDialects": [
"browser",
"node"
],
"commonjsDialects": [
"browser",
"node"
],
"exports": {
"./raw": "./src/index.ts",
".": {
"import": {
"browser": {
"types": "./dist/esm/browser/index.d.ts",
"default": "./dist/esm/browser/index.min.js"
},
"node": {
"types": "./dist/esm/node/index.d.ts",
"default": "./dist/esm/node/index.min.js"
},
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.min.js"
},
"require": {
"browser": {
"types": "./dist/commonjs/browser/index.d.ts",
"default": "./dist/commonjs/browser/index.min.js"
},
"node": {
"types": "./dist/commonjs/node/index.d.ts",
"default": "./dist/commonjs/node/index.min.js"
},
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.min.js"
}
}
},
"selfLink": false
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/isaacs/node-lru-cache.git"
},
"devDependencies": {
"benchmark": "^2.1.4",
"esbuild": "^0.25.9",
"marked": "^4.2.12",
"mkdirp": "^3.0.1",
"oxlint": "^1.58.0",
"oxlint-tsgolint": "^0.19.0",
"prettier": "^3.8.1",
"tap": "^21.7.2",
"tshy": "^4.1.1",
"typedoc": "^0.28.18"
},
"license": "BlueOak-1.0.0",
"files": [
"dist"
],
"engines": {
"node": "20 || >=22"
},
"exports": {
"./raw": {
"import": {
"browser": {
"types": "./dist/esm/browser/index.d.ts",
"default": "./dist/esm/browser/index.js"
},
"node": {
"types": "./dist/esm/node/index.d.ts",
"default": "./dist/esm/node/index.js"
},
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"browser": {
"types": "./dist/commonjs/browser/index.d.ts",
"default": "./dist/commonjs/browser/index.js"
},
"node": {
"types": "./dist/commonjs/node/index.d.ts",
"default": "./dist/commonjs/node/index.js"
},
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
},
".": {
"import": {
"browser": {
"types": "./dist/esm/browser/index.d.ts",
"default": "./dist/esm/browser/index.min.js"
},
"node": {
"types": "./dist/esm/node/index.d.ts",
"default": "./dist/esm/node/index.min.js"
},
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.min.js"
},
"require": {
"browser": {
"types": "./dist/commonjs/browser/index.d.ts",
"default": "./dist/commonjs/browser/index.min.js"
},
"node": {
"types": "./dist/commonjs/node/index.d.ts",
"default": "./dist/commonjs/node/index.min.js"
},
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.min.js"
}
}
},
"type": "module",
"module": "./dist/esm/index.min.js"
}

55
patcher/vendor/asar/node_modules/minimatch/LICENSE.md generated vendored Normal file
View file

@ -0,0 +1,55 @@
# Blue Oak Model License
Version 1.0.0
## Purpose
This license gives everyone as much permission to work with
this software as possible, while protecting contributors
from liability.
## Acceptance
In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
**_As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim._**

528
patcher/vendor/asar/node_modules/minimatch/README.md generated vendored Normal file
View file

@ -0,0 +1,528 @@
# minimatch
A minimal matching utility.
This is the matching library used internally by npm.
It works by converting glob expressions into JavaScript `RegExp`
objects.
## Important Security Consideration!
> [!WARNING]
> This library uses JavaScript regular expressions. Please read
> the following warning carefully, and be thoughtful about what
> you provide to this library in production systems.
_Any_ library in JavaScript that deals with matching string
patterns using regular expressions will be subject to
[ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
if the pattern is generated using untrusted input.
Efforts have been made to mitigate risk as much as is feasible in
such a library, providing maximum recursion depths and so forth,
but these measures can only ultimately protect against accidents,
not malice. A dedicated attacker can _always_ find patterns that
cannot be defended against by a bash-compatible glob pattern
matching system that uses JavaScript regular expressions.
To be extremely clear:
> [!WARNING]
> **If you create a system where you take user input, and use
> that input as the source of a Regular Expression pattern, in
> this or any extant glob matcher in JavaScript, you will be
> pwned.**
A future version of this library _may_ use a different matching
algorithm which does not exhibit backtracking problems. If and
when that happens, it will likely be a sweeping change, and those
improvements will **not** be backported to legacy versions.
In the near term, it is not reasonable to continue to play
whack-a-mole with security advisories, and so any future ReDoS
reports will be considered "working as intended", and resolved
entirely by this warning.
## Usage
```js
// hybrid module, load with require() or import
import { minimatch } from 'minimatch'
// or:
const { minimatch } = require('minimatch')
minimatch('bar.foo', '*.foo') // true!
minimatch('bar.foo', '*.bar') // false!
minimatch('bar.foo', '*.+(bar|foo)', { debug: true }) // true, and noisy!
```
## Features
Supports these glob features:
- Brace Expansion
- Extended glob matching
- "Globstar" `**` matching
- [Posix character
classes](https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html),
like `[[:alpha:]]`, supporting the full range of Unicode
characters. For example, `[[:alpha:]]` will match against
`'é'`, though `[a-zA-Z]` will not. Collating symbol and set
matching is not supported, so `[[=e=]]` will _not_ match `'é'`
and `[[.ch.]]` will not match `'ch'` in locales where `ch` is
considered a single character.
See:
- `man sh`
- `man bash` [Pattern
Matching](https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html)
- `man 3 fnmatch`
- `man 5 gitignore`
## Windows
**Please only use forward-slashes in glob expressions.**
Though windows uses either `/` or `\` as its path separator, only `/`
characters are used by this glob implementation. You must use
forward-slashes **only** in glob expressions. Back-slashes in patterns
will always be interpreted as escape characters, not path separators.
Note that `\` or `/` _will_ be interpreted as path separators in paths on
Windows, and will match against `/` in glob expressions.
So just always use `/` in patterns.
### UNC Paths
On Windows, UNC paths like `//?/c:/...` or
`//ComputerName/Share/...` are handled specially.
- Patterns starting with a double-slash followed by some
non-slash characters will preserve their double-slash. As a
result, a pattern like `//*` will match `//x`, but not `/x`.
- Patterns staring with `//?/<drive letter>:` will _not_ treat
the `?` as a wildcard character. Instead, it will be treated
as a normal string.
- Patterns starting with `//?/<drive letter>:/...` will match
file paths starting with `<drive letter>:/...`, and vice versa,
as if the `//?/` was not present. This behavior only is
present when the drive letters are a case-insensitive match to
one another. The remaining portions of the path/pattern are
compared case sensitively, unless `nocase:true` is set.
Note that specifying a UNC path using `\` characters as path
separators is always allowed in the file path argument, but only
allowed in the pattern argument when `windowsPathsNoEscape: true`
is set in the options.
## Minimatch Class
Create a minimatch object by instantiating the `minimatch.Minimatch` class.
```javascript
var Minimatch = require('minimatch').Minimatch
var mm = new Minimatch(pattern, options)
```
### Properties
- `pattern` The original pattern the minimatch object represents.
- `options` The options supplied to the constructor.
- `set` A 2-dimensional array of regexp or string expressions.
Each row in the
array corresponds to a brace-expanded pattern. Each item in the row
corresponds to a single path-part. For example, the pattern
`{a,b/c}/d` would expand to a set of patterns like:
[ [ a, d ]
, [ b, c, d ] ]
If a portion of the pattern doesn't have any "magic" in it
(that is, it's something like `"foo"` rather than `fo*o?`), then it
will be left as a string rather than converted to a regular
expression.
- `regexp` Created by the `makeRe` method. A single regular expression
expressing the entire pattern. This is useful in cases where you wish
to use the pattern somewhat like `fnmatch(3)` with `FNM_PATH` enabled.
- `negate` True if the pattern is negated.
- `comment` True if the pattern is a comment.
- `empty` True if the pattern is `""`.
### Methods
- `makeRe()` Generate the `regexp` member if necessary, and return it.
Will return `false` if the pattern is invalid.
- `match(fname)` Return true if the filename matches the pattern, or
false otherwise.
- `matchOne(fileArray, patternArray, partial)` Take a `/`-split
filename, and match it against a single row in the `regExpSet`. This
method is mainly for internal use, but is exposed so that it can be
used by a glob-walker that needs to avoid excessive filesystem calls.
- `hasMagic()` Returns true if the parsed pattern contains any
magic characters. Returns false if all comparator parts are
string literals. If the `magicalBraces` option is set on the
constructor, then it will consider brace expansions which are
not otherwise magical to be magic. If not set, then a pattern
like `a{b,c}d` will return `false`, because neither `abd` nor
`acd` contain any special glob characters.
This does **not** mean that the pattern string can be used as a
literal filename, as it may contain magic glob characters that
are escaped. For example, the pattern `\\*` or `[*]` would not
be considered to have magic, as the matching portion parses to
the literal string `'*'` and would match a path named `'*'`,
not `'\\*'` or `'[*]'`. The `minimatch.unescape()` method may
be used to remove escape characters.
All other methods are internal, and will be called as necessary.
### minimatch(path, pattern, options)
Main export. Tests a path against the pattern using the options.
```javascript
var isJS = minimatch(file, '*.js', { matchBase: true })
```
### minimatch.filter(pattern, options)
Returns a function that tests its
supplied argument, suitable for use with `Array.filter`. Example:
```javascript
var javascripts = fileList.filter(
minimatch.filter('*.js', { matchBase: true }),
)
```
### minimatch.escape(pattern, options = {})
Escape all magic characters in a glob pattern, so that it will
only ever match literal strings.
If the `windowsPathsNoEscape` option is used, then characters are
escaped by wrapping in `[]`, because a magic character wrapped in
a character class can only be satisfied by that exact character.
Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot
be escaped or unescaped.
### minimatch.unescape(pattern, options = {})
Un-escape a glob string that may contain some escaped characters.
If the `windowsPathsNoEscape` option is used, then square-brace
escapes are removed, but not backslash escapes. For example, it
will turn the string `'[*]'` into `*`, but it will not turn
`'\\*'` into `'*'`, because `\` is a path separator in
`windowsPathsNoEscape` mode.
When `windowsPathsNoEscape` is not set, then both brace escapes
and backslash escapes are removed.
Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot
be escaped or unescaped.
### minimatch.match(list, pattern, options)
Match against the list of
files, in the style of fnmatch or glob. If nothing is matched, and
options.nonull is set, then return a list containing the pattern itself.
```javascript
var javascripts = minimatch.match(fileList, '*.js', { matchBase: true })
```
### minimatch.makeRe(pattern, options)
Make a regular expression object from the pattern.
## Options
All options are `false` by default.
### debug
Dump a ton of stuff to stderr.
### nobrace
Do not expand `{a,b}` and `{1..3}` brace sets.
### noglobstar
Disable `**` matching against multiple folder names.
### dot
Allow patterns to match filenames starting with a period, even if
the pattern does not explicitly have a period in that spot.
Note that by default, `a/**/b` will **not** match `a/.d/b`, unless `dot`
is set.
### noext
Disable "extglob" style patterns like `+(a|b)`.
### nocase
Perform a case-insensitive match.
### nocaseMagicOnly
When used with `{nocase: true}`, create regular expressions that
are case-insensitive, but leave string match portions untouched.
Has no effect when used without `{nocase: true}`.
Useful when some other form of case-insensitive matching is used,
or if the original string representation is useful in some other
way.
### nonull
When a match is not found by `minimatch.match`, return a list containing
the pattern itself if this option is set. When not set, an empty list
is returned if there are no matches.
### magicalBraces
This only affects the results of the `Minimatch.hasMagic` method.
If the pattern contains brace expansions, such as `a{b,c}d`, but
no other magic characters, then the `Minimatch.hasMagic()` method
will return `false` by default. When this option set, it will
return `true` for brace expansion as well as other magic glob
characters.
### matchBase
If set, then patterns without slashes will be matched
against the basename of the path if it contains slashes. For example,
`a?b` would match the path `/xyz/123/acb`, but not `/xyz/acb/123`.
### nocomment
Suppress the behavior of treating `#` at the start of a pattern as a
comment.
### nonegate
Suppress the behavior of treating a leading `!` character as negation.
### flipNegate
Returns from negate expressions the same as if they were not negated.
(Ie, true on a hit, false on a miss.)
### partial
Compare a partial path to a pattern. As long as the parts of the path that
are present are not contradicted by the pattern, it will be treated as a
match. This is useful in applications where you're walking through a
folder structure, and don't yet have the full path, but want to ensure that
you do not walk down paths that can never be a match.
For example,
```js
minimatch('/a/b', '/a/*/c/d', { partial: true }) // true, might be /a/b/c/d
minimatch('/a/b', '/**/d', { partial: true }) // true, might be /a/b/.../d
minimatch('/x/y/z', '/a/**/z', { partial: true }) // false, because x !== a
```
### windowsPathsNoEscape
Use `\\` as a path separator _only_, and _never_ as an escape
character. If set, all `\\` characters are replaced with `/` in
the pattern. Note that this makes it **impossible** to match
against paths containing literal glob pattern characters, but
allows matching with patterns constructed using `path.join()` and
`path.resolve()` on Windows platforms, mimicking the (buggy!)
behavior of earlier versions on Windows. Please use with
caution, and be mindful of [the caveat about Windows
paths](#windows).
For legacy reasons, this is also set if
`options.allowWindowsEscape` is set to the exact value `false`.
### windowsNoMagicRoot
When a pattern starts with a UNC path or drive letter, and in
`nocase:true` mode, do not convert the root portions of the
pattern into a case-insensitive regular expression, and instead
leave them as strings.
This is the default when the platform is `win32` and
`nocase:true` is set.
### preserveMultipleSlashes
By default, multiple `/` characters (other than the leading `//`
in a UNC path, see "UNC Paths" above) are treated as a single
`/`.
That is, a pattern like `a///b` will match the file path `a/b`.
Set `preserveMultipleSlashes: true` to suppress this behavior.
### optimizationLevel
A number indicating the level of optimization that should be done
to the pattern prior to parsing and using it for matches.
Globstar parts `**` are always converted to `*` when `noglobstar`
is set, and multiple adjacent `**` parts are converted into a
single `**` (ie, `a/**/**/b` will be treated as `a/**/b`, as this
is equivalent in all cases).
- `0` - Make no further changes. In this mode, `.` and `..` are
maintained in the pattern, meaning that they must also appear
in the same position in the test path string. Eg, a pattern
like `a/*/../c` will match the string `a/b/../c` but not the
string `a/c`.
- `1` - (default) Remove cases where a double-dot `..` follows a
pattern portion that is not `**`, `.`, `..`, or empty `''`. For
example, the pattern `./a/b/../*` is converted to `./a/*`, and
so it will match the path string `./a/c`, but not the path
string `./a/b/../c`. Dots and empty path portions in the
pattern are preserved.
- `2` (or higher) - Much more aggressive optimizations, suitable
for use with file-walking cases:
- Remove cases where a double-dot `..` follows a pattern
portion that is not `**`, `.`, or empty `''`. Remove empty
and `.` portions of the pattern, where safe to do so (ie,
anywhere other than the last position, the first position, or
the second position in a pattern starting with `/`, as this
may indicate a UNC path on Windows).
- Convert patterns containing `<pre>/**/../<p>/<rest>` into the
equivalent `<pre>/{..,**}/<p>/<rest>`, where `<p>` is a
a pattern portion other than `.`, `..`, `**`, or empty
`''`.
- Dedupe patterns where a `**` portion is present in one and
omitted in another, and it is not the final path portion, and
they are otherwise equivalent. So `{a/**/b,a/b}` becomes
`a/**/b`, because `**` matches against an empty path portion.
- Dedupe patterns where a `*` portion is present in one, and a
non-dot pattern other than `**`, `.`, `..`, or `''` is in the
same position in the other. So `a/{*,x}/b` becomes `a/*/b`,
because `*` can match against `x`.
While these optimizations improve the performance of
file-walking use cases such as [glob](http://npm.im/glob) (ie,
the reason this module exists), there are cases where it will
fail to match a literal string that would have been matched in
optimization level 1 or 0.
Specifically, while the `Minimatch.match()` method will
optimize the file path string in the same ways, resulting in
the same matches, it will fail when tested with the regular
expression provided by `Minimatch.makeRe()`, unless the path
string is first processed with
`minimatch.levelTwoFileOptimize()` or similar.
### platform
When set to `win32`, this will trigger all windows-specific
behaviors (special handling for UNC paths, and treating `\` as
separators in file paths for comparison.)
Defaults to the value of `process.platform`.
### maxGlobstarRecursion
Max number of non-adjacent `**` patterns to recursively walk
down.
The default of `200` is almost certainly high enough for most
purposes, and can handle absurdly excessive patterns.
If the limit is exceeded (which would require very excessively
long patterns and paths containing lots of `**` patterns!), then
it is treated as non-matching, even if the path would normally
match the pattern provided.
That is, this is an intentional false negative, deemed an
acceptable break in correctness for security and performance.
### maxExtglobRecursion
Max depth to traverse for nested extglobs like `*(a|b|c)`
Default is 2, which is quite low, but any higher value swiftly
results in punishing performance impacts. Note that this is _not_
relevant when the globstar types can be safely coalesced into a
single set.
For example, `*(a|@(b|c)|d)` would be flattened into
`*(a|b|c|d)`. Thus, many common extglobs will retain good
performance and never hit this limit, even if they are
excessively deep and complicated.
If the limit is hit, then the extglob characters are simply not
parsed, and the pattern effectively switches into `noextglob:
true` mode for the contents of that nested sub-pattern. This will
typically _not_ result in a match, but is considered a valid
trade-off for security and performance.
## Comparisons to other fnmatch/glob implementations
While strict compliance with the existing standards is a
worthwhile goal, some discrepancies exist between minimatch and
other implementations. Some are intentional, and some are
unavoidable.
If the pattern starts with a `!` character, then it is negated. Set the
`nonegate` flag to suppress this behavior, and treat leading `!`
characters normally. This is perhaps relevant if you wish to start the
pattern with a negative extglob pattern like `!(a|B)`. Multiple `!`
characters at the start of a pattern will negate the pattern multiple
times.
If a pattern starts with `#`, then it is treated as a comment, and
will not match anything. Use `\#` to match a literal `#` at the
start of a line, or set the `nocomment` flag to suppress this behavior.
The double-star character `**` is supported by default, unless the
`noglobstar` flag is set. This is supported in the manner of bsdglob
and bash 4.1, where `**` only has special significance if it is the only
thing in a path part. That is, `a/**/b` will match `a/x/y/b`, but
`a/**b` will not.
If an escaped pattern has no matches, and the `nonull` flag is set,
then minimatch.match returns the pattern as-provided, rather than
interpreting the character escapes. For example,
`minimatch.match([], "\\*a\\?")` will return `"\\*a\\?"` rather than
`"*a?"`. This is akin to setting the `nullglob` option in bash, except
that it does not resolve escaped pattern characters.
If brace expansion is not disabled, then it is performed before any
other interpretation of the glob pattern. Thus, a pattern like
`+(a|{b),c)}`, which would not be valid in bash or zsh, is expanded
**first** into the set of `+(a|b)` and `+(a|c)`, and those patterns are
checked for validity. Since those two are valid, matching proceeds.
Negated extglob patterns are handled as closely as possible to
Bash semantics, but there are some cases with negative extglobs
which are exceedingly difficult to express in a JavaScript
regular expression. In particular the negated pattern
`<start>!(<pattern>*|)*` will in bash match anything that does
not start with `<start><pattern>`. However,
`<start>!(<pattern>*)*` _will_ match paths starting with
`<start><pattern>`, because the empty string can match against
the negated portion. In this library, `<start>!(<pattern>*|)*`
will _not_ match any pattern starting with `<start>`, due to a
difference in precisely which patterns are considered "greedy" in
Regular Expressions vs bash path expansion. This may be fixable,
but not without incurring some complexity and performance costs,
and the trade-off seems to not be worth pursuing.
Note that `fnmatch(3)` in libc is an extremely naive string comparison
matcher, which does not do anything special for slashes. This library is
designed to be used in glob searching and file walkers, and so it does do
special things with `/`. Thus, `foo*` will not match `foo/bar` in this
library, even though it would in `fnmatch(3)`.

View file

@ -0,0 +1,73 @@
{
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
"name": "minimatch",
"description": "a glob matcher in javascript",
"version": "10.2.5",
"repository": {
"type": "git",
"url": "git@github.com:isaacs/minimatch"
},
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"files": [
"dist"
],
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"prepare": "tshy",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write .",
"benchmark": "node benchmark/index.js",
"typedoc": "typedoc --tsconfig .tshy/esm.json ./src/*.ts",
"lint": "oxlint --fix src test",
"postsnap": "npm run lint",
"postlint": "npm run format"
},
"engines": {
"node": "18 || 20 || >=22"
},
"devDependencies": {
"@types/node": "^25.5.0",
"mkdirp": "^3.0.1",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.18.1",
"prettier": "^3.8.1",
"tap": "^21.6.2",
"tshy": "^4.0.0",
"typedoc": "^0.28.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"license": "BlueOak-1.0.0",
"tshy": {
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
},
"selfLink": false
},
"type": "module",
"module": "./dist/esm/index.js",
"dependencies": {
"brace-expansion": "^5.0.5"
}
}

55
patcher/vendor/asar/node_modules/minipass/LICENSE.md generated vendored Normal file
View file

@ -0,0 +1,55 @@
# Blue Oak Model License
Version 1.0.0
## Purpose
This license gives everyone as much permission to work with
this software as possible, while protecting contributors
from liability.
## Acceptance
In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***

825
patcher/vendor/asar/node_modules/minipass/README.md generated vendored Normal file
View file

@ -0,0 +1,825 @@
# minipass
A _very_ minimal implementation of a [PassThrough
stream](https://nodejs.org/api/stream.html#stream_class_stream_passthrough)
[It's very
fast](https://docs.google.com/spreadsheets/d/1K_HR5oh3r80b8WVMWCPPjfuWXUgfkmhlX7FGI6JJ8tY/edit?usp=sharing)
for objects, strings, and buffers.
Supports `pipe()`ing (including multi-`pipe()` and backpressure
transmission), buffering data until either a `data` event handler
or `pipe()` is added (so you don't lose the first chunk), and
most other cases where PassThrough is a good idea.
There is a `read()` method, but it's much more efficient to
consume data from this stream via `'data'` events or by calling
`pipe()` into some other stream. Calling `read()` requires the
buffer to be flattened in some cases, which requires copying
memory.
If you set `objectMode: true` in the options, then whatever is
written will be emitted. Otherwise, it'll do a minimal amount of
Buffer copying to ensure proper Streams semantics when `read(n)`
is called.
`objectMode` can only be set at instantiation. Attempting to
write something other than a String or Buffer without having set
`objectMode` in the options will throw an error.
This is not a `through` or `through2` stream. It doesn't
transform the data, it just passes it right through. If you want
to transform the data, extend the class, and override the
`write()` method. Once you're done transforming the data however
you want, call `super.write()` with the transform output.
For some examples of streams that extend Minipass in various
ways, check out:
- [minizlib](http://npm.im/minizlib)
- [fs-minipass](http://npm.im/fs-minipass)
- [tar](http://npm.im/tar)
- [minipass-collect](http://npm.im/minipass-collect)
- [minipass-flush](http://npm.im/minipass-flush)
- [minipass-pipeline](http://npm.im/minipass-pipeline)
- [tap](http://npm.im/tap)
- [tap-parser](http://npm.im/tap-parser)
- [treport](http://npm.im/treport)
- [minipass-fetch](http://npm.im/minipass-fetch)
- [pacote](http://npm.im/pacote)
- [make-fetch-happen](http://npm.im/make-fetch-happen)
- [cacache](http://npm.im/cacache)
- [ssri](http://npm.im/ssri)
- [npm-registry-fetch](http://npm.im/npm-registry-fetch)
- [minipass-json-stream](http://npm.im/minipass-json-stream)
- [minipass-sized](http://npm.im/minipass-sized)
## Usage in TypeScript
The `Minipass` class takes three type template definitions:
- `RType` the type being read, which defaults to `Buffer`. If
`RType` is `string`, then the constructor _must_ get an options
object specifying either an `encoding` or `objectMode: true`.
If it's anything other than `string` or `Buffer`, then it
_must_ get an options object specifying `objectMode: true`.
- `WType` the type being written. If `RType` is `Buffer` or
`string`, then this defaults to `ContiguousData` (Buffer,
string, ArrayBuffer, or ArrayBufferView). Otherwise, it
defaults to `RType`.
- `Events` type mapping event names to the arguments emitted
with that event, which extends `Minipass.Events`.
To declare types for custom events in subclasses, extend the
third parameter with your own event signatures. For example:
```js
import { Minipass } from 'minipass'
// a NDJSON stream that emits 'jsonError' when it can't stringify
export interface Events extends Minipass.Events {
jsonError: [e: Error]
}
export class NDJSONStream extends Minipass<string, any, Events> {
constructor() {
super({ objectMode: true })
}
// data is type `any` because that's WType
write(data, encoding, cb) {
try {
const json = JSON.stringify(data)
return super.write(json + '\n', encoding, cb)
} catch (er) {
if (!er instanceof Error) {
er = Object.assign(new Error('json stringify failed'), {
cause: er,
})
}
// trying to emit with something OTHER than an error will
// fail, because we declared the event arguments type.
this.emit('jsonError', er)
}
}
}
const s = new NDJSONStream()
s.on('jsonError', e => {
// here, TS knows that e is an Error
})
```
Emitting/handling events that aren't declared in this way is
fine, but the arguments will be typed as `unknown`.
## Differences from Node.js Streams
There are several things that make Minipass streams different
from (and in some ways superior to) Node.js core streams.
Please read these caveats if you are familiar with node-core
streams and intend to use Minipass streams in your programs.
You can avoid most of these differences entirely (for a very
small performance penalty) by setting `{async: true}` in the
constructor options.
### Timing
Minipass streams are designed to support synchronous use-cases.
Thus, data is emitted as soon as it is available, always. It is
buffered until read, but no longer. Another way to look at it is
that Minipass streams are exactly as synchronous as the logic
that writes into them.
This can be surprising if your code relies on
`PassThrough.write()` always providing data on the next tick
rather than the current one, or being able to call `resume()` and
not have the entire buffer disappear immediately.
However, without this synchronicity guarantee, there would be no
way for Minipass to achieve the speeds it does, or support the
synchronous use cases that it does. Simply put, waiting takes
time.
This non-deferring approach makes Minipass streams much easier to
reason about, especially in the context of Promises and other
flow-control mechanisms.
Example:
```js
// hybrid module, either works
import { Minipass } from 'minipass'
// or:
const { Minipass } = require('minipass')
const stream = new Minipass()
stream.on('data', () => console.log('data event'))
console.log('before write')
stream.write('hello')
console.log('after write')
// output:
// before write
// data event
// after write
```
### Exception: Async Opt-In
If you wish to have a Minipass stream with behavior that more
closely mimics Node.js core streams, you can set the stream in
async mode either by setting `async: true` in the constructor
options, or by setting `stream.async = true` later on.
```js
// hybrid module, either works
import { Minipass } from 'minipass'
// or:
const { Minipass } = require('minipass')
const asyncStream = new Minipass({ async: true })
asyncStream.on('data', () => console.log('data event'))
console.log('before write')
asyncStream.write('hello')
console.log('after write')
// output:
// before write
// after write
// data event <-- this is deferred until the next tick
```
Switching _out_ of async mode is unsafe, as it could cause data
corruption, and so is not enabled. Example:
```js
import { Minipass } from 'minipass'
const stream = new Minipass({ encoding: 'utf8' })
stream.on('data', chunk => console.log(chunk))
stream.async = true
console.log('before writes')
stream.write('hello')
setStreamSyncAgainSomehow(stream) // <-- this doesn't actually exist!
stream.write('world')
console.log('after writes')
// hypothetical output would be:
// before writes
// world
// after writes
// hello
// NOT GOOD!
```
To avoid this problem, once set into async mode, any attempt to
make the stream sync again will be ignored.
```js
const { Minipass } = require('minipass')
const stream = new Minipass({ encoding: 'utf8' })
stream.on('data', chunk => console.log(chunk))
stream.async = true
console.log('before writes')
stream.write('hello')
stream.async = false // <-- no-op, stream already async
stream.write('world')
console.log('after writes')
// actual output:
// before writes
// after writes
// hello
// world
```
### No High/Low Water Marks
Node.js core streams will optimistically fill up a buffer,
returning `true` on all writes until the limit is hit, even if
the data has nowhere to go. Then, they will not attempt to draw
more data in until the buffer size dips below a minimum value.
Minipass streams are much simpler. The `write()` method will
return `true` if the data has somewhere to go (which is to say,
given the timing guarantees, that the data is already there by
the time `write()` returns).
If the data has nowhere to go, then `write()` returns false, and
the data sits in a buffer, to be drained out immediately as soon
as anyone consumes it.
Since nothing is ever buffered unnecessarily, there is much less
copying data, and less bookkeeping about buffer capacity levels.
### Hazards of Buffering (or: Why Minipass Is So Fast)
Since data written to a Minipass stream is immediately written
all the way through the pipeline, and `write()` always returns
true/false based on whether the data was fully flushed,
backpressure is communicated immediately to the upstream caller.
This minimizes buffering.
Consider this case:
```js
const { PassThrough } = require('stream')
const p1 = new PassThrough({ highWaterMark: 1024 })
const p2 = new PassThrough({ highWaterMark: 1024 })
const p3 = new PassThrough({ highWaterMark: 1024 })
const p4 = new PassThrough({ highWaterMark: 1024 })
p1.pipe(p2).pipe(p3).pipe(p4)
p4.on('data', () => console.log('made it through'))
// this returns false and buffers, then writes to p2 on next tick (1)
// p2 returns false and buffers, pausing p1, then writes to p3 on next tick (2)
// p3 returns false and buffers, pausing p2, then writes to p4 on next tick (3)
// p4 returns false and buffers, pausing p3, then emits 'data' and 'drain'
// on next tick (4)
// p3 sees p4's 'drain' event, and calls resume(), emitting 'resume' and
// 'drain' on next tick (5)
// p2 sees p3's 'drain', calls resume(), emits 'resume' and 'drain' on next tick (6)
// p1 sees p2's 'drain', calls resume(), emits 'resume' and 'drain' on next
// tick (7)
p1.write(Buffer.alloc(2048)) // returns false
```
Along the way, the data was buffered and deferred at each stage,
and multiple event deferrals happened, for an unblocked pipeline
where it was perfectly safe to write all the way through!
Furthermore, setting a `highWaterMark` of `1024` might lead
someone reading the code to think an advisory maximum of 1KiB is
being set for the pipeline. However, the actual advisory
buffering level is the _sum_ of `highWaterMark` values, since
each one has its own bucket.
Consider the Minipass case:
```js
const m1 = new Minipass()
const m2 = new Minipass()
const m3 = new Minipass()
const m4 = new Minipass()
m1.pipe(m2).pipe(m3).pipe(m4)
m4.on('data', () => console.log('made it through'))
// m1 is flowing, so it writes the data to m2 immediately
// m2 is flowing, so it writes the data to m3 immediately
// m3 is flowing, so it writes the data to m4 immediately
// m4 is flowing, so it fires the 'data' event immediately, returns true
// m4's write returned true, so m3 is still flowing, returns true
// m3's write returned true, so m2 is still flowing, returns true
// m2's write returned true, so m1 is still flowing, returns true
// No event deferrals or buffering along the way!
m1.write(Buffer.alloc(2048)) // returns true
```
It is extremely unlikely that you _don't_ want to buffer any data
written, or _ever_ buffer data that can be flushed all the way
through. Neither node-core streams nor Minipass ever fail to
buffer written data, but node-core streams do a lot of
unnecessary buffering and pausing.
As always, the faster implementation is the one that does less
stuff and waits less time to do it.
### Immediately emit `end` for empty streams (when not paused)
If a stream is not paused, and `end()` is called before writing
any data into it, then it will emit `end` immediately.
If you have logic that occurs on the `end` event which you don't
want to potentially happen immediately (for example, closing file
descriptors, moving on to the next entry in an archive parse
stream, etc.) then be sure to call `stream.pause()` on creation,
and then `stream.resume()` once you are ready to respond to the
`end` event.
However, this is _usually_ not a problem because:
### Emit `end` When Asked
One hazard of immediately emitting `'end'` is that you may not
yet have had a chance to add a listener. In order to avoid this
hazard, Minipass streams safely re-emit the `'end'` event if a
new listener is added after `'end'` has been emitted.
Ie, if you do `stream.on('end', someFunction)`, and the stream
has already emitted `end`, then it will call the handler right
away. (You can think of this somewhat like attaching a new
`.then(fn)` to a previously-resolved Promise.)
To prevent calling handlers multiple times who would not expect
multiple ends to occur, all listeners are removed from the
`'end'` event whenever it is emitted.
### Emit `error` When Asked
The most recent error object passed to the `'error'` event is
stored on the stream. If a new `'error'` event handler is added,
and an error was previously emitted, then the event handler will
be called immediately (or on `process.nextTick` in the case of
async streams).
This makes it much more difficult to end up trying to interact
with a broken stream, if the error handler is added after an
error was previously emitted.
### Impact of "immediate flow" on Tee-streams
A "tee stream" is a stream piping to multiple destinations:
```js
const tee = new Minipass()
t.pipe(dest1)
t.pipe(dest2)
t.write('foo') // goes to both destinations
```
Since Minipass streams _immediately_ process any pending data
through the pipeline when a new pipe destination is added, this
can have surprising effects, especially when a stream comes in
from some other function and may or may not have data in its
buffer.
```js
// WARNING! WILL LOSE DATA!
const src = new Minipass()
src.write('foo')
src.pipe(dest1) // 'foo' chunk flows to dest1 immediately, and is gone
src.pipe(dest2) // gets nothing!
```
One solution is to create a dedicated tee-stream junction that
pipes to both locations, and then pipe to _that_ instead.
```js
// Safe example: tee to both places
const src = new Minipass()
src.write('foo')
const tee = new Minipass()
tee.pipe(dest1)
tee.pipe(dest2)
src.pipe(tee) // tee gets 'foo', pipes to both locations
```
The same caveat applies to `on('data')` event listeners. The
first one added will _immediately_ receive all of the data,
leaving nothing for the second:
```js
// WARNING! WILL LOSE DATA!
const src = new Minipass()
src.write('foo')
src.on('data', handler1) // receives 'foo' right away
src.on('data', handler2) // nothing to see here!
```
Using a dedicated tee-stream can be used in this case as well:
```js
// Safe example: tee to both data handlers
const src = new Minipass()
src.write('foo')
const tee = new Minipass()
tee.on('data', handler1)
tee.on('data', handler2)
src.pipe(tee)
```
All of the hazards in this section are avoided by setting `{
async: true }` in the Minipass constructor, or by setting
`stream.async = true` afterwards. Note that this does add some
overhead, so should only be done in cases where you are willing
to lose a bit of performance in order to avoid having to refactor
program logic.
## USAGE
It's a stream! Use it like a stream and it'll most likely do what
you want.
```js
import { Minipass } from 'minipass'
const mp = new Minipass(options) // options is optional
mp.write('foo')
mp.pipe(someOtherStream)
mp.end('bar')
```
### OPTIONS
- `encoding` How would you like the data coming _out_ of the
stream to be encoded? Accepts any values that can be passed to
`Buffer.toString()`.
- `objectMode` Emit data exactly as it comes in. This will be
flipped on by default if you write() something other than a
string or Buffer at any point. Setting `objectMode: true` will
prevent setting any encoding value.
- `async` Defaults to `false`. Set to `true` to defer data
emission until next tick. This reduces performance slightly,
but makes Minipass streams use timing behavior closer to Node
core streams. See [Timing](#timing) for more details.
- `signal` An `AbortSignal` that will cause the stream to unhook
itself from everything and become as inert as possible. Note
that providing a `signal` parameter will make `'error'` events
no longer throw if they are unhandled, but they will still be
emitted to handlers if any are attached.
### API
Implements the user-facing portions of Node.js's `Readable` and
`Writable` streams.
### Methods
- `write(chunk, [encoding], [callback])` - Put data in. (Note
that, in the base Minipass class, the same data will come out.)
Returns `false` if the stream will buffer the next write, or
true if it's still in "flowing" mode.
- `end([chunk, [encoding]], [callback])` - Signal that you have
no more data to write. This will queue an `end` event to be
fired when all the data has been consumed.
- `pause()` - No more data for a while, please. This also
prevents `end` from being emitted for empty streams until the
stream is resumed.
- `resume()` - Resume the stream. If there's data in the buffer,
it is all discarded. Any buffered events are immediately
emitted.
- `pipe(dest)` - Send all output to the stream provided. When
data is emitted, it is immediately written to any and all pipe
destinations. (Or written on next tick in `async` mode.)
- `unpipe(dest)` - Stop piping to the destination stream. This is
immediate, meaning that any asynchronously queued data will
_not_ make it to the destination when running in `async` mode.
- `options.end` - Boolean, end the destination stream when the
source stream ends. Default `true`.
- `options.proxyErrors` - Boolean, proxy `error` events from
the source stream to the destination stream. Note that errors
are _not_ proxied after the pipeline terminates, either due
to the source emitting `'end'` or manually unpiping with
`src.unpipe(dest)`. Default `false`.
- `on(ev, fn)`, `emit(ev, fn)` - Minipass streams are
EventEmitters. Some events are given special treatment,
however. (See below under "events".)
- `promise()` - Returns a Promise that resolves when the stream
emits `end`, or rejects if the stream emits `error`.
- `collect()` - Return a Promise that resolves on `end` with an
array containing each chunk of data that was emitted, or
rejects if the stream emits `error`. Note that this consumes
the stream data.
- `concat()` - Same as `collect()`, but concatenates the data
into a single Buffer object. Will reject the returned promise
if the stream is in objectMode, or if it goes into objectMode
by the end of the data.
- `read(n)` - Consume `n` bytes of data out of the buffer. If `n`
is not provided, then consume all of it. If `n` bytes are not
available, then it returns null. **Note** consuming streams in
this way is less efficient, and can lead to unnecessary Buffer
copying.
- `destroy([er])` - Destroy the stream. If an error is provided,
then an `'error'` event is emitted. If the stream has a
`close()` method, and has not emitted a `'close'` event yet,
then `stream.close()` will be called. Any Promises returned by
`.promise()`, `.collect()` or `.concat()` will be rejected.
After being destroyed, writing to the stream will emit an
error. No more data will be emitted if the stream is destroyed,
even if it was previously buffered.
### Properties
- `bufferLength` Read-only. Total number of bytes buffered, or in
the case of objectMode, the total number of objects.
- `encoding` Read-only. The encoding that has been set.
- `flowing` Read-only. Boolean indicating whether a chunk written
to the stream will be immediately emitted.
- `emittedEnd` Read-only. Boolean indicating whether the end-ish
events (ie, `end`, `prefinish`, `finish`) have been emitted.
Note that listening on any end-ish event will immediateyl
re-emit it if it has already been emitted.
- `writable` Whether the stream is writable. Default `true`. Set
to `false` when `end()`
- `readable` Whether the stream is readable. Default `true`.
- `pipes` An array of Pipe objects referencing streams that this
stream is piping into.
- `destroyed` A getter that indicates whether the stream was
destroyed.
- `paused` True if the stream has been explicitly paused,
otherwise false.
- `objectMode` Indicates whether the stream is in `objectMode`.
- `aborted` Readonly property set when the `AbortSignal`
dispatches an `abort` event.
### Events
- `data` Emitted when there's data to read. Argument is the data
to read. This is never emitted while not flowing. If a listener
is attached, that will resume the stream.
- `end` Emitted when there's no more data to read. This will be
emitted immediately for empty streams when `end()` is called.
If a listener is attached, and `end` was already emitted, then
it will be emitted again. All listeners are removed when `end`
is emitted.
- `prefinish` An end-ish event that follows the same logic as
`end` and is emitted in the same conditions where `end` is
emitted. Emitted after `'end'`.
- `finish` An end-ish event that follows the same logic as `end`
and is emitted in the same conditions where `end` is emitted.
Emitted after `'prefinish'`.
- `close` An indication that an underlying resource has been
released. Minipass does not emit this event, but will defer it
until after `end` has been emitted, since it throws off some
stream libraries otherwise.
- `drain` Emitted when the internal buffer empties, and it is
again suitable to `write()` into the stream.
- `readable` Emitted when data is buffered and ready to be read
by a consumer.
- `resume` Emitted when stream changes state from buffering to
flowing mode. (Ie, when `resume` is called, `pipe` is called,
or a `data` event listener is added.)
### Static Methods
- `Minipass.isStream(stream)` Returns `true` if the argument is a
stream, and false otherwise. To be considered a stream, the
object must be either an instance of Minipass, or an
EventEmitter that has either a `pipe()` method, or both
`write()` and `end()` methods. (Pretty much any stream in
node-land will return `true` for this.)
## EXAMPLES
Here are some examples of things you can do with Minipass
streams.
### simple "are you done yet" promise
```js
mp.promise().then(
() => {
// stream is finished
},
er => {
// stream emitted an error
}
)
```
### collecting
```js
mp.collect().then(all => {
// all is an array of all the data emitted
// encoding is supported in this case, so
// so the result will be a collection of strings if
// an encoding is specified, or buffers/objects if not.
//
// In an async function, you may do
// const data = await stream.collect()
})
```
### collecting into a single blob
This is a bit slower because it concatenates the data into one
chunk for you, but if you're going to do it yourself anyway, it's
convenient this way:
```js
mp.concat().then(onebigchunk => {
// onebigchunk is a string if the stream
// had an encoding set, or a buffer otherwise.
})
```
### iteration
You can iterate over streams synchronously or asynchronously in
platforms that support it.
Synchronous iteration will end when the currently available data
is consumed, even if the `end` event has not been reached. In
string and buffer mode, the data is concatenated, so unless
multiple writes are occurring in the same tick as the `read()`,
sync iteration loops will generally only have a single iteration.
To consume chunks in this way exactly as they have been written,
with no flattening, create the stream with the `{ objectMode:
true }` option.
```js
const mp = new Minipass({ objectMode: true })
mp.write('a')
mp.write('b')
for (let letter of mp) {
console.log(letter) // a, b
}
mp.write('c')
mp.write('d')
for (let letter of mp) {
console.log(letter) // c, d
}
mp.write('e')
mp.end()
for (let letter of mp) {
console.log(letter) // e
}
for (let letter of mp) {
console.log(letter) // nothing
}
```
Asynchronous iteration will continue until the end event is reached,
consuming all of the data.
```js
const mp = new Minipass({ encoding: 'utf8' })
// some source of some data
let i = 5
const inter = setInterval(() => {
if (i-- > 0) mp.write(Buffer.from('foo\n', 'utf8'))
else {
mp.end()
clearInterval(inter)
}
}, 100)
// consume the data with asynchronous iteration
async function consume() {
for await (let chunk of mp) {
console.log(chunk)
}
return 'ok'
}
consume().then(res => console.log(res))
// logs `foo\n` 5 times, and then `ok`
```
### subclass that `console.log()`s everything written into it
```js
class Logger extends Minipass {
write(chunk, encoding, callback) {
console.log('WRITE', chunk, encoding)
return super.write(chunk, encoding, callback)
}
end(chunk, encoding, callback) {
console.log('END', chunk, encoding)
return super.end(chunk, encoding, callback)
}
}
someSource.pipe(new Logger()).pipe(someDest)
```
### same thing, but using an inline anonymous class
```js
// js classes are fun
someSource
.pipe(
new (class extends Minipass {
emit(ev, ...data) {
// let's also log events, because debugging some weird thing
console.log('EMIT', ev)
return super.emit(ev, ...data)
}
write(chunk, encoding, callback) {
console.log('WRITE', chunk, encoding)
return super.write(chunk, encoding, callback)
}
end(chunk, encoding, callback) {
console.log('END', chunk, encoding)
return super.end(chunk, encoding, callback)
}
})()
)
.pipe(someDest)
```
### subclass that defers 'end' for some reason
```js
class SlowEnd extends Minipass {
emit(ev, ...args) {
if (ev === 'end') {
console.log('going to end, hold on a sec')
setTimeout(() => {
console.log('ok, ready to end now')
super.emit('end', ...args)
}, 100)
return true
} else {
return super.emit(ev, ...args)
}
}
}
```
### transform that creates newline-delimited JSON
```js
class NDJSONEncode extends Minipass {
write(obj, cb) {
try {
// JSON.stringify can throw, emit an error on that
return super.write(JSON.stringify(obj) + '\n', 'utf8', cb)
} catch (er) {
this.emit('error', er)
}
}
end(obj, cb) {
if (typeof obj === 'function') {
cb = obj
obj = undefined
}
if (obj !== undefined) {
this.write(obj)
}
return super.end(cb)
}
}
```
### transform that parses newline-delimited JSON
```js
class NDJSONDecode extends Minipass {
constructor(options) {
// always be in object mode, as far as Minipass is concerned
super({ objectMode: true })
this._jsonBuffer = ''
}
write(chunk, encoding, cb) {
if (
typeof chunk === 'string' &&
typeof encoding === 'string' &&
encoding !== 'utf8'
) {
chunk = Buffer.from(chunk, encoding).toString()
} else if (Buffer.isBuffer(chunk)) {
chunk = chunk.toString()
}
if (typeof encoding === 'function') {
cb = encoding
}
const jsonData = (this._jsonBuffer + chunk).split('\n')
this._jsonBuffer = jsonData.pop()
for (let i = 0; i < jsonData.length; i++) {
try {
// JSON.parse can throw, emit an error on that
super.write(JSON.parse(jsonData[i]))
} catch (er) {
this.emit('error', er)
continue
}
}
if (cb) cb()
}
}
```

77
patcher/vendor/asar/node_modules/minipass/package.json generated vendored Normal file
View file

@ -0,0 +1,77 @@
{
"name": "minipass",
"version": "7.1.3",
"description": "minimal implementation of a PassThrough stream",
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js",
"type": "module",
"tshy": {
"selfLink": false,
"compiler": "tsgo",
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
}
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"files": [
"dist"
],
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"prepare": "tshy",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write . --loglevel warn",
"typedoc": "typedoc --tsconfig .tshy/esm.json ./src/*.ts"
},
"prettier": {
"semi": false,
"printWidth": 75,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"jsxSingleQuote": false,
"bracketSameLine": true,
"arrowParens": "avoid",
"endOfLine": "lf"
},
"devDependencies": {
"@types/end-of-stream": "^1.4.2",
"@types/node": "^25.2.3",
"end-of-stream": "^1.4.0",
"node-abort-controller": "^3.1.1",
"prettier": "^3.8.1",
"tap": "^21.6.1",
"through2": "^2.0.3",
"tshy": "^3.3.2",
"typedoc": "^0.28.17"
},
"repository": "https://github.com/isaacs/minipass",
"keywords": [
"passthrough",
"stream"
],
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
}

View file

@ -0,0 +1,55 @@
# Blue Oak Model License
Version 1.0.0
## Purpose
This license gives everyone as much permission to work with
this software as possible, while protecting contributors
from liability.
## Acceptance
In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***

636
patcher/vendor/asar/node_modules/path-scurry/README.md generated vendored Normal file
View file

@ -0,0 +1,636 @@
# path-scurry
Extremely high performant utility for building tools that read
the file system, minimizing filesystem and path string munging
operations to the greatest degree possible.
## Ugh, yet another file traversal thing on npm?
Yes. None of the existing ones gave me exactly what I wanted.
## Well what is it you wanted?
While working on [glob](http://npm.im/glob), I found that I
needed a module to very efficiently manage the traversal over a
folder tree, such that:
1. No `readdir()` or `stat()` would ever be called on the same
file or directory more than one time.
2. No `readdir()` calls would be made if we can be reasonably
sure that the path is not a directory. (Ie, a previous
`readdir()` or `stat()` covered the path, and
`ent.isDirectory()` is false.)
3. `path.resolve()`, `dirname()`, `basename()`, and other
string-parsing/munging operations are be minimized. This means
it has to track "provisional" child nodes that may not exist
(and if we find that they _don't_ exist, store that
information as well, so we don't have to ever check again).
4. The API is not limited to use as a stream/iterator/etc. There
are many cases where an API like node's `fs` is preferrable.
5. It's more important to prevent excess syscalls than to be up
to date, but it should be smart enough to know what it
_doesn't_ know, and go get it seamlessly when requested.
6. Do not blow up the JS heap allocation if operating on a
directory with a huge number of entries.
7. Handle all the weird aspects of Windows paths, like UNC paths
and drive letters and wrongway slashes, so that the consumer
can return canonical platform-specific paths without having to
parse or join or do any error-prone string munging.
## PERFORMANCE
JavaScript people throw around the word "blazing" a lot. I hope
that this module doesn't blaze anyone. But it does go very fast,
in the cases it's optimized for, if used properly.
PathScurry provides ample opportunities to get extremely good
performance, as well as several options to trade performance for
convenience.
Benchmarks can be run by executing `npm run bench`.
As is always the case, doing more means going slower, doing less
means going faster, and there are trade offs between speed and
memory usage.
PathScurry makes heavy use of [LRUCache](http://npm.im/lru-cache)
to efficiently cache whatever it can, and `Path` objects remain
in the graph for the lifetime of the walker, so repeated calls
with a single PathScurry object will be extremely fast. However,
adding items to a cold cache means "doing more", so in those
cases, we pay a price. Nothing is free, but every effort has been
made to reduce costs wherever possible.
Also, note that a "cache as long as possible" approach means that
changes to the filesystem may not be reflected in the results of
repeated PathScurry operations.
For resolving string paths, `PathScurry` ranges from 5-50 times
faster than `path.resolve` on repeated resolutions, but around
100 to 1000 times _slower_ on the first resolution. If your
program is spending a lot of time resolving the _same_ paths
repeatedly (like, thousands or millions of times), then this can
be beneficial. But both implementations are pretty fast, and
speeding up an infrequent operation from 4µs to 400ns is not
going to move the needle on your app's performance.
For walking file system directory trees, a lot depends on how
often a given PathScurry object will be used, and also on the
walk method used.
With default settings on a folder tree of 100,000 items,
consisting of around a 10-to-1 ratio of normal files to
directories, PathScurry performs comparably to
[@nodelib/fs.walk](http://npm.im/@nodelib/fs.walk), which is the
fastest and most reliable file system walker I could find. As far
as I can tell, it's almost impossible to go much faster in a
Node.js program, just based on how fast you can push syscalls out
to the fs thread pool.
On my machine, that is about 1000-1200 completed walks per second
for async or stream walks, and around 500-600 walks per second
synchronously.
In the warm cache state, PathScurry's performance increases
around 4x for async `for await` iteration, 10-15x faster for
streams and synchronous `for of` iteration, and anywhere from 30x
to 80x faster for the rest.
```
# walk 100,000 fs entries, 10/1 file/dir ratio
# operations / ms
New PathScurry object | Reuse PathScurry object
stream: 1112.589 | 13974.917
sync stream: 492.718 | 15028.343
async walk: 1095.648 | 32706.395
sync walk: 527.632 | 46129.772
async iter: 1288.821 | 5045.510
sync iter: 498.496 | 17920.746
```
A hand-rolled walk calling `entry.readdir()` and recursing
through the entries can benefit even more from caching, with
greater flexibility and without the overhead of streams or
generators.
The cold cache state is still limited by the costs of file system
operations, but with a warm cache, the only bottleneck is CPU
speed and VM optimizations. Of course, in that case, some care
must be taken to ensure that you don't lose performance as a
result of silly mistakes, like calling `readdir()` on entries
that you know are not directories.
```
# manual recursive iteration functions
cold cache | warm cache
async: 1164.901 | 17923.320
cb: 1101.127 | 40999.344
zalgo: 1082.240 | 66689.936
sync: 526.935 | 87097.591
```
In this case, the speed improves by around 10-20x in the async
case, 40x in the case of using `entry.readdirCB` with protections
against synchronous callbacks, and 50-100x with callback
deferrals disabled, and _several hundred times faster_ for
synchronous iteration.
If you can think of a case that is not covered in these
benchmarks, or an implementation that performs significantly
better than PathScurry, please [let me
know](https://github.com/isaacs/path-scurry/issues).
## USAGE
```ts
// hybrid module, load with either method
import { PathScurry, Path } from 'path-scurry'
// or:
const { PathScurry, Path } = require('path-scurry')
// very simple example, say we want to find and
// delete all the .DS_Store files in a given path
// note that the API is very similar to just a
// naive walk with fs.readdir()
import { unlink } from 'fs/promises'
// easy way, iterate over the directory and do the thing
const pw = new PathScurry(process.cwd())
for await (const entry of pw) {
if (entry.isFile() && entry.name === '.DS_Store') {
unlink(entry.fullpath())
}
}
// here it is as a manual recursive method
const walk = async (entry: Path) => {
const promises: Promise<any> = []
// readdir doesn't throw on non-directories, it just doesn't
// return any entries, to save stack trace costs.
// Items are returned in arbitrary unsorted order
for (const child of await pw.readdir(entry)) {
// each child is a Path object
if (child.name === '.DS_Store' && child.isFile()) {
// could also do pw.resolve(entry, child.name),
// just like fs.readdir walking, but .fullpath is
// a *slightly* more efficient shorthand.
promises.push(unlink(child.fullpath()))
} else if (child.isDirectory()) {
promises.push(walk(child))
}
}
return Promise.all(promises)
}
walk(pw.cwd).then(() => {
console.log('all .DS_Store files removed')
})
const pw2 = new PathScurry('/a/b/c') // pw2.cwd is the Path for /a/b/c
const relativeDir = pw2.cwd.resolve('../x') // Path entry for '/a/b/x'
const relative2 = pw2.cwd.resolve('/a/b/d/../x') // same path, same entry
assert.equal(relativeDir, relative2)
```
## API
[Full TypeDoc API](https://isaacs.github.io/path-scurry)
There are platform-specific classes exported, but for the most
part, the default `PathScurry` and `Path` exports are what you
most likely need, unless you are testing behavior for other
platforms.
Intended public API is documented here, but the full
documentation does include internal types, which should not be
accessed directly.
### Interface `PathScurryOpts`
The type of the `options` argument passed to the `PathScurry`
constructor.
- `nocase`: Boolean indicating that file names should be compared
case-insensitively. Defaults to `true` on darwin and win32
implementations, `false` elsewhere.
**Warning** Performing case-insensitive matching on a
case-sensitive filesystem will result in occasionally very
bizarre behavior. Performing case-sensitive matching on a
case-insensitive filesystem may negatively impact performance.
- `childrenCacheSize`: Number of child entries to cache, in order
to speed up `resolve()` and `readdir()` calls. Defaults to
`16 * 1024` (ie, `16384`).
Setting it to a higher value will run the risk of JS heap
allocation errors on large directory trees. Setting it to `256`
or smaller will significantly reduce the construction time and
data consumption overhead, but with the downside of operations
being slower on large directory trees. Setting it to `0` will
mean that effectively no operations are cached, and this module
will be roughly the same speed as `fs` for file system
operations, and _much_ slower than `path.resolve()` for
repeated path resolution.
- `fs` An object that will be used to override the default `fs`
methods. Any methods that are not overridden will use Node's
built-in implementations.
- lstatSync
- readdir (callback `withFileTypes` Dirent variant, used for
readdirCB and most walks)
- readdirSync
- readlinkSync
- realpathSync
- promises: Object containing the following async methods:
- lstat
- readdir (Dirent variant only)
- readlink
- realpath
### Interface `WalkOptions`
The options object that may be passed to all walk methods.
- `withFileTypes`: Boolean, default true. Indicates that `Path`
objects should be returned. Set to `false` to get string paths
instead.
- `follow`: Boolean, default false. Attempt to read directory
entries from symbolic links. Otherwise, only actual directories
are traversed. Regardless of this setting, a given target path
will only ever be walked once, meaning that a symbolic link to
a previously traversed directory will never be followed.
Setting this imposes a slight performance penalty, because
`readlink` must be called on all symbolic links encountered, in
order to avoid infinite cycles.
- `filter`: Function `(entry: Path) => boolean`. If provided,
will prevent the inclusion of any entry for which it returns a
falsey value. This will not prevent directories from being
traversed if they do not pass the filter, though it will
prevent the directories themselves from being included in the
results. By default, if no filter is provided, then all entries
are included in the results.
- `walkFilter`: Function `(entry: Path) => boolean`. If provided,
will prevent the traversal of any directory (or in the case of
`follow:true` symbolic links to directories) for which the
function returns false. This will not prevent the directories
themselves from being included in the result set. Use `filter`
for that.
Note that TypeScript return types will only be inferred properly
from static analysis if the `withFileTypes` option is omitted, or
a constant `true` or `false` value.
### Class `PathScurry`
The main interface. Defaults to an appropriate class based on the
current platform.
Use `PathScurryWin32`, `PathScurryDarwin`, or `PathScurryPosix`
if implementation-specific behavior is desired.
All walk methods may be called with a `WalkOptions` argument to
walk over the object's current working directory with the
supplied options.
#### `async pw.walk(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Walk the directory tree according to the options provided,
resolving to an array of all entries found.
#### `pw.walkSync(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Walk the directory tree according to the options provided,
returning an array of all entries found.
#### `pw.iterate(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Iterate over the directory asynchronously, for use with `for
await of`. This is also the default async iterator method.
#### `pw.iterateSync(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Iterate over the directory synchronously, for use with `for of`.
This is also the default sync iterator method.
#### `pw.stream(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Return a [Minipass](http://npm.im/minipass) stream that emits
each entry or path string in the walk. Results are made available
asynchronously.
#### `pw.streamSync(entry?: string | Path | WalkOptions, opts?: WalkOptions)`
Return a [Minipass](http://npm.im/minipass) stream that emits
each entry or path string in the walk. Results are made available
synchronously, meaning that the walk will complete in a single
tick if the stream is fully consumed.
#### `pw.cwd`
Path object representing the current working directory for the
PathScurry.
#### `pw.chdir(path: string)`
Set the new effective current working directory for the scurry
object, so that `path.relative()` and `path.relativePosix()`
return values relative to the new cwd path.
#### `pw.depth(path?: Path | string): number`
Return the depth of the specified path (or the PathScurry cwd)
within the directory tree.
Root entries have a depth of `0`.
#### `pw.resolve(...paths: string[])`
Caching `path.resolve()`.
Significantly faster than `path.resolve()` if called repeatedly
with the same paths. Significantly slower otherwise, as it builds
out the cached Path entries.
To get a `Path` object resolved from the `PathScurry`, use
`pw.cwd.resolve(path)`. Note that `Path.resolve` only takes a
single string argument, not multiple.
#### `pw.resolvePosix(...paths: string[])`
Caching `path.resolve()`, but always using posix style paths.
This is identical to `pw.resolve(...paths)` on posix systems (ie,
everywhere except Windows).
On Windows, it returns the full absolute UNC path using `/`
separators. Ie, instead of `'C:\\foo\\bar`, it would return
`//?/C:/foo/bar`.
#### `pw.relative(path: string | Path): string`
Return the relative path from the PathWalker cwd to the supplied
path string or entry.
If the nearest common ancestor is the root, then an absolute path
is returned.
#### `pw.relativePosix(path: string | Path): string`
Return the relative path from the PathWalker cwd to the supplied
path string or entry, using `/` path separators.
If the nearest common ancestor is the root, then an absolute path
is returned.
On posix platforms (ie, all platforms except Windows), this is
identical to `pw.relative(path)`.
On Windows systems, it returns the resulting string as a
`/`-delimited path. If an absolute path is returned (because the
target does not share a common ancestor with `pw.cwd`), then a
full absolute UNC path will be returned. Ie, instead of
`'C:\\foo\\bar`, it would return `//?/C:/foo/bar`.
#### `pw.basename(path: string | Path): string`
Return the basename of the provided string or Path.
#### `pw.dirname(path: string | Path): string`
Return the parent directory of the supplied string or Path.
#### `async pw.readdir(dir = pw.cwd, opts = { withFileTypes: true })`
Read the directory and resolve to an array of strings if
`withFileTypes` is explicitly set to `false` or Path objects
otherwise.
Can be called as `pw.readdir({ withFileTypes: boolean })` as
well.
Returns `[]` if no entries are found, or if any error occurs.
Note that TypeScript return types will only be inferred properly
from static analysis if the `withFileTypes` option is omitted, or
a constant `true` or `false` value.
#### `pw.readdirSync(dir = pw.cwd, opts = { withFileTypes: true })`
Synchronous `pw.readdir()`
#### `async pw.readlink(link = pw.cwd, opts = { withFileTypes: false })`
Call `fs.readlink` on the supplied string or Path object, and
return the result.
Can be called as `pw.readlink({ withFileTypes: boolean })` as
well.
Returns `undefined` if any error occurs (for example, if the
argument is not a symbolic link), or a `Path` object if
`withFileTypes` is explicitly set to `true`, or a string
otherwise.
Note that TypeScript return types will only be inferred properly
from static analysis if the `withFileTypes` option is omitted, or
a constant `true` or `false` value.
#### `pw.readlinkSync(link = pw.cwd, opts = { withFileTypes: false })`
Synchronous `pw.readlink()`
#### `async pw.lstat(entry = pw.cwd)`
Call `fs.lstat` on the supplied string or Path object, and fill
in as much information as possible, returning the updated `Path`
object.
Returns `undefined` if the entry does not exist, or if any error
is encountered.
Note that some `Stats` data (such as `ino`, `dev`, and `mode`)
will not be supplied. For those things, you'll need to call
`fs.lstat` yourself.
#### `pw.lstatSync(entry = pw.cwd)`
Synchronous `pw.lstat()`
#### `pw.realpath(entry = pw.cwd, opts = { withFileTypes: false })`
Call `fs.realpath` on the supplied string or Path object, and
return the realpath if available.
Returns `undefined` if any error occurs.
May be called as `pw.realpath({ withFileTypes: boolean })` to run
on `pw.cwd`.
#### `pw.realpathSync(entry = pw.cwd, opts = { withFileTypes: false })`
Synchronous `pw.realpath()`
### Class `Path` implements [fs.Dirent](https://nodejs.org/docs/latest/api/fs.html#class-fsdirent)
Object representing a given path on the filesystem, which may or
may not exist.
Note that the actual class in use will be either `PathWin32` or
`PathPosix`, depending on the implementation of `PathScurry` in
use. They differ in the separators used to split and join path
strings, and the handling of root paths.
In `PathPosix` implementations, paths are split and joined using
the `'/'` character, and `'/'` is the only root path ever in use.
In `PathWin32` implementations, paths are split using either
`'/'` or `'\\'` and joined using `'\\'`, and multiple roots may
be in use based on the drives and UNC paths encountered. UNC
paths such as `//?/C:/` that identify a drive letter, will be
treated as an alias for the same root entry as their associated
drive letter (in this case `'C:\\'`).
#### `path.name`
Name of this file system entry.
**Important**: _always_ test the path name against any test
string using the `isNamed` method, and not by directly comparing
this string. Otherwise, unicode path strings that the system sees
as identical will not be properly treated as the same path,
leading to incorrect behavior and possible security issues.
#### `path.isNamed(name: string): boolean`
Return true if the path is a match for the given path name. This
handles case sensitivity and unicode normalization.
Note: even on case-sensitive systems, it is **not** safe to test
the equality of the `.name` property to determine whether a given
pathname matches, due to unicode normalization mismatches.
Always use this method instead of testing the `path.name`
property directly.
#### `path.isCWD`
Set to true if this `Path` object is the current working
directory of the `PathScurry` collection that contains it.
#### `path.getType()`
Returns the type of the Path object, `'File'`, `'Directory'`,
etc.
#### `path.isType(t: type)`
Returns true if `is{t}()` returns true.
For example, `path.isType('Directory')` is equivalent to
`path.isDirectory()`.
#### `path.depth()`
Return the depth of the Path entry within the directory tree.
Root paths have a depth of `0`.
#### `path.fullpath()`
The fully resolved path to the entry.
#### `path.fullpathPosix()`
The fully resolved path to the entry, using `/` separators.
On posix systems, this is identical to `path.fullpath()`. On
windows, this will return a fully resolved absolute UNC path
using `/` separators. Eg, instead of `'C:\\foo\\bar'`, it will
return `'//?/C:/foo/bar'`.
#### `path.isFile()`, `path.isDirectory()`, etc.
Same as the identical `fs.Dirent.isX()` methods.
#### `path.isUnknown()`
Returns true if the path's type is unknown. Always returns true
when the path is known to not exist.
#### `path.resolve(p: string)`
Return a `Path` object associated with the provided path string
as resolved from the current Path object.
#### `path.relative(): string`
Return the relative path from the PathWalker cwd to the supplied
path string or entry.
If the nearest common ancestor is the root, then an absolute path
is returned.
#### `path.relativePosix(): string`
Return the relative path from the PathWalker cwd to the supplied
path string or entry, using `/` path separators.
If the nearest common ancestor is the root, then an absolute path
is returned.
On posix platforms (ie, all platforms except Windows), this is
identical to `pw.relative(path)`.
On Windows systems, it returns the resulting string as a
`/`-delimited path. If an absolute path is returned (because the
target does not share a common ancestor with `pw.cwd`), then a
full absolute UNC path will be returned. Ie, instead of
`'C:\\foo\\bar`, it would return `//?/C:/foo/bar`.
#### `async path.readdir()`
Return an array of `Path` objects found by reading the associated
path entry.
If path is not a directory, or if any error occurs, returns `[]`,
and marks all children as provisional and non-existent.
#### `path.readdirSync()`
Synchronous `path.readdir()`
#### `async path.readlink()`
Return the `Path` object referenced by the `path` as a symbolic
link.
If the `path` is not a symbolic link, or any error occurs,
returns `undefined`.
#### `path.readlinkSync()`
Synchronous `path.readlink()`
#### `async path.lstat()`
Call `lstat` on the path object, and fill it in with details
determined.
If path does not exist, or any other error occurs, returns
`undefined`, and marks the path as "unknown" type.
#### `path.lstatSync()`
Synchronous `path.lstat()`
#### `async path.realpath()`
Call `realpath` on the path, and return a Path object
corresponding to the result, or `undefined` if any error occurs.
#### `path.realpathSync()`
Synchornous `path.realpath()`

View file

@ -0,0 +1,72 @@
{
"name": "path-scurry",
"version": "2.0.2",
"description": "walk paths fast and efficiently",
"author": "Isaac Z. Schlueter <i@izs.me> (https://blog.izs.me)",
"main": "./dist/commonjs/index.js",
"type": "module",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"files": [
"dist"
],
"license": "BlueOak-1.0.0",
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"prepare": "tshy",
"pretest": "npm run prepare",
"presnap": "npm run prepare",
"test": "tap",
"snap": "tap",
"format": "prettier --write . --log-level warn",
"typedoc": "typedoc --tsconfig tsconfig-esm.json ./src/*.ts",
"bench": "bash ./scripts/bench.sh"
},
"devDependencies": {
"@nodelib/fs.walk": "^3.0.1",
"@types/node": "^25.3.0",
"mkdirp": "^3.0.0",
"prettier": "^3.3.2",
"rimraf": "^6.1.3",
"tap": "^21.6.1",
"ts-node": "^10.9.2",
"tshy": "^3.3.2",
"typedoc": "^0.28.17"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"repository": {
"type": "git",
"url": "git+https://github.com/isaacs/path-scurry"
},
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"tshy": {
"selfLink": false,
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
}
},
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js"
}

9
patcher/vendor/asar/package.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"name": "bettercf-asar-vendor",
"version": "1.0.0",
"description": "Vendored @electron/asar for offline, zero-delay CurseForge patching",
"private": true,
"dependencies": {
"@electron/asar": "^4.3.1"
}
}