1 Commits

Author SHA1 Message Date
88d2a2133c Merge pull request 'chore: bump version to 1.2.0 and commit recent fixes/features including tray and auth isolation' (#4) from release/v1.2.0 into main
All checks were successful
Build and Release / build (push) Successful in 5m37s
Reviewed-on: #4
2026-02-21 03:21:45 +01:00
81 changed files with 751 additions and 16146 deletions

13
.gitignore vendored
View File

@@ -1,13 +1,5 @@
# Dependencies # Dependencies
**/# Arch Linux Build Artifacts **/node_modules/
pkg/
src/
*.pkg.tar.zst
*.src.tar.gz
ultimate-ban-tracker-*.tar.gz
# Node.js
node_modules/
/.pnp /.pnp
.pnp.js .pnp.js
@@ -54,6 +46,3 @@ frontend/.env
# release files # release files
release/ release/
pkg/

View File

@@ -1,77 +0,0 @@
# Maintainer: Nils Pukropp <nils@narl.io>
pkgname=ultimate-ban-tracker
pkgver=1.3.3
pkgrel=6
pkgdesc="Professional Steam Account Manager & Ban Tracker"
arch=('x86_64')
url="https://narl.io"
license=('custom:Personal Use and Non-Commercial')
depends=('electron' 'nodejs' 'npm' 'libxss' 'nss' 'libxtst' 'libappindicator-gtk3' 'libsecret' 'libdbusmenu-gtk3')
makedepends=('imagemagick')
source=("ultimate-ban-tracker-${pkgver}.tar.gz::https://git.narl.io/nvrl/ultimate-ban-tracker/archive/v${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/ultimate-ban-tracker"
# Clean state
rm -rf frontend/node_modules frontend/package-lock.json
cd frontend
# Fresh install with all dependencies
npm install
# Ensure icon is converted (IMv7 compatible)
if command -v magick &> /dev/null; then
magick -background none -size 512x512 assets-build/icon.svg assets-build/icon.png
else
convert -background none -size 512x512 assets-build/icon.svg assets-build/icon.png
fi
# Build production binary for linux specifically
npm run electron:build -- --linux
}
package() {
cd "${srcdir}/ultimate-ban-tracker/frontend"
# Create directory structure
install -d "${pkgdir}/usr/lib/${pkgname}"
install -d "${pkgdir}/usr/bin"
install -d "${pkgdir}/usr/share/applications"
install -d "${pkgdir}/usr/share/pixmaps"
# Copy the unpacked linux build
cp -r release/linux-unpacked/* "${pkgdir}/usr/lib/${pkgname}/"
# Fix permissions for chrome-sandbox
chmod 4755 "${pkgdir}/usr/lib/${pkgname}/chrome-sandbox"
# Create wrapper script
cat > "${pkgdir}/usr/bin/${pkgname}" <<EOF
#!/bin/bash
export ELECTRON_OZONE_PLATFORM_HINT=auto
export XDG_CURRENT_DESKTOP=Unity
exec /usr/lib/${pkgname}/${pkgname}-desktop "\$@"
EOF
chmod +x "${pkgdir}/usr/bin/${pkgname}"
# Install Desktop Entry
cat > "${pkgdir}/usr/share/applications/${pkgname}.desktop" <<EOF
[Desktop Entry]
Name=Ultimate Ban Tracker
Exec=/usr/bin/${pkgname} %U
Icon=${pkgname}
Type=Application
Categories=Game;Utility;
Terminal=false
Comment=Professional Steam Account Manager & Ban Tracker
StartupWMClass=${pkgname}-desktop
EOF
# Install Icons
install -m644 "assets-build/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
# Install License
install -D -m644 "../LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View File

@@ -1,54 +1,47 @@
# Ultimate Ban Tracker # Ultimate Ban Tracker
Ultimate Ban Tracker is a standalone desktop application for Windows and Linux designed to manage multiple Steam accounts and monitor ban statuses. It features an automated account switcher, stealth cooldown tracking, and an optional community-driven synchronization system. Ultimate Ban Tracker is a standalone desktop application for Windows and Linux designed to manage multiple Steam accounts and monitor ban statuses. It features an automated account switcher and an optional community-driven synchronization system for sharing account access and cooldown data.
## Features ## Features
### Account Management ### Account Management
* **One-Click Switching**: Switch between Steam accounts instantly. The application manages authentication tokens and registry keys directly, bypassing the need for manual login or Steam Guard codes. * **Credential-free Login**: Switch between Steam accounts instantly. The application extracts and injects authentication tokens directly into local Steam configuration files, bypassing the need for manual credential entry or Steam Guard codes on every switch.
* **Session Synchronization**: Optional cross-machine sync via a dedicated backend. Authenticate once, and the encrypted session is available to authorized community members. * **Session Synchronization**: Sync login states across multiple machines via the optional community server. Authenticate once, and the session is available to authorized community members.
* **Auto-Discovery**: Detects and imports local Steam accounts automatically. * **Auto-Discovery**: Automatically detects and imports accounts currently logged into the local Steam client.
* **Isolated Sessions**: Uses per-account browser partitions to prevent session leakage during authentication.
### Monitoring and Tracking ### Monitoring and Tracking
* **Cooldown Tracking**: Automated scraping of Steam Personal Game Data (GCPD) for live Counter-Strike competitive cooldown countdowns. * **Cooldown Tracking**: Scrapes Steam Personal Game Data (GCPD) to provide live countdowns for Counter-Strike competitive cooldowns.
* **Stealth Sync**: Uses randomized intervals and non-blocking background workers to avoid Steam web detection. * **Distributed Scraping**: Cooldown data is aggregated on the community server; only one active client is required to keep the status updated for all users tracking that account.
* **Ban Status**: Real-time monitoring for VAC and developer game bans with high-signal indicators. * **Ban Detection**: Real-time monitoring for VAC and developer game bans.
### Desktop Integration ### User Interface
* **System Tray**: Persistent tray icon for quick account switching and synchronization without opening the main window. * **Dynamic Theming**: Includes several built-in color schemes including Steam Classic, Catppuccin (Mocha/Latte), Nord, and Tokyo Night.
* **Minimize-to-Tray**: The application runs in the background to ensure continuous ban monitoring and data freshness. * **Denisty-Focused Design**: Compact list view allows for monitoring a large number of accounts simultaneously.
* **Dynamic Theming**: Support for Steam Classic, Catppuccin (Mocha/Latte), Nord, and Tokyo Night. App icons automatically adapt to match the selected theme.
## Installation ## Installation
### For Users ### For Users
The recommended way to use Ultimate Ban Tracker is to download a pre-built installer from the [Releases](https://git.narl.io/nvrl/ultimate-ban-tracker/releases) page. Download the latest pre-built installer for your operating system from the [Releases](https://git.narl.io/nvrl/ultimate-ban-tracker/releases) page.
* **Windows**: `.exe` (NSIS Installer) * **Windows**: Download the `.exe` installer.
* **Linux**: `.AppImage` (Portable) or `.deb` (Ubuntu/Debian) * **Linux**: Download the `.AppImage` (portable) or `.deb` (Debian/Ubuntu) package.
### Arch Linux (makepkg)
For Arch-based distributions, a `PKGBUILD` is provided in the repository root.
```bash
git clone https://git.narl.io/nvrl/ultimate-ban-tracker.git
cd ultimate-ban-tracker
makepkg -si
```
### For Developers ### For Developers
Requires Node.js v22+ and an active Steam installation. Local development requires Node.js v22 or higher and an active Steam installation.
1. `cd frontend && npm install`
2. `npm run electron:dev` (Development mode) 1. Clone the repository.
3. `npm run electron:build` (Production packaging) 2. Navigate to the `frontend` directory.
3. Install dependencies: `npm install`.
4. Start the development environment: `npm run electron:dev`.
5. To build production binaries: `npm run electron:build`.
## Community Server ## Community Server
Backend functionality for sharing and synchronization is optional and hosted separately: The backend server required for community features (sharing and sync) is hosted in a separate repository:
[Ultimate Ban Tracker Server](https://git.narl.io/nvrl/ultimate-ban-tracker-server) [Ultimate Ban Tracker Server](https://git.narl.io/nvrl/ultimate-ban-tracker-server)
## Security ## Security
* **AES-256-GCM Encryption**: All sensitive credentials (cookies and config blobs) are encrypted before being synced to the community server. * **Encryption**: All sensitive data, including Steam cookies and configuration blobs, are encrypted using AES-256-GCM before being synchronized with the backend.
* **Local-First Storage**: Data is stored locally using `electron-store`. Usage of the community server is strictly optional. * **Local Storage**: Account data is stored locally using `electron-store`. Usage of the community server is optional.
* **Atomic Operations**: VDF file updates use atomic write strategies to prevent Steam configuration corruption. * **Session Isolation**: Steam authentication is performed in an isolated browser partition that is cleared after each session to prevent credential leakage.
## License ## License
Personal Use and Non-Commercial License. Created by Nils Pukropp. See `LICENSE` file for details. ISC License. Created by Nils Pukropp.

View File

@@ -1,33 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs> <rect width="512" height="512" rx="64" fill="#171A21"/>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"> <path d="M256 64C150.13 64 64 150.13 64 256C64 361.87 150.13 448 256 448C361.87 448 448 361.87 448 256C448 150.13 375.73 64 256 64ZM256 405.33C173.6 405.33 106.67 338.4 106.67 256C106.67 221.33 118.4 189.33 138.13 164.27L347.73 373.87C322.67 393.6 290.67 405.33 256 405.33ZM373.87 347.73L164.27 138.13C189.33 118.4 221.33 106.67 256 106.67C338.4 106.67 405.33 173.6 405.33 256C405.33 290.67 393.6 322.67 373.87 347.73Z" fill="#66C0F4"/>
<stop offset="0%" stop-color="#1B2838"/>
<stop offset="100%" stop-color="#101419"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#66C0F4"/>
<stop offset="100%" stop-color="#1A9FFF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<!-- Outer Rounded Container -->
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<rect x="2" y="2" width="508" height="508" rx="98" stroke="white" stroke-opacity="0.05" stroke-width="4"/>
<!-- Tracking Ring (Detailed) -->
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<!-- Central Shield Symbol -->
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<!-- "Ban" Intersect (Stylized Cross) -->
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
<!-- Glass Highlight -->
<path d="M100 100C150 60 362 60 412 100" stroke="white" stroke-opacity="0.1" stroke-width="20" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#EFF1F5"/>
<stop offset="100%" stop-color="#DCE0E8"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1E66F5"/>
<stop offset="100%" stop-color="#179299"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#1E66F5" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#EFF1F5" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1E1E2E"/>
<stop offset="100%" stop-color="#11111B"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#B4BEFE"/>
<stop offset="100%" stop-color="#89B4FA"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#B4BEFE" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1E1E2E" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#3B4252"/>
<stop offset="100%" stop-color="#2E3440"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#88C0D0"/>
<stop offset="100%" stop-color="#81A1C1"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#88C0D0" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#3B4252" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1B2838"/>
<stop offset="100%" stop-color="#101419"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#66C0F4"/>
<stop offset="100%" stop-color="#1A9FFF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1A1B26"/>
<stop offset="100%" stop-color="#10101A"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7AA2F7"/>
<stop offset="100%" stop-color="#3D59A1"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#7AA2F7" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1A1B26" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -18,11 +18,6 @@ const backend_1 = require("./services/backend");
// Reliable isDev check // Reliable isDev check
const isDev = !electron_1.app.isPackaged; const isDev = !electron_1.app.isPackaged;
electron_1.app.name = "Ultimate Ban Tracker"; electron_1.app.name = "Ultimate Ban Tracker";
// Force Wayland/Ozone support if on Linux
if (process.platform === 'linux') {
electron_1.app.commandLine.appendSwitch('enable-features', 'UseOzonePlatform');
electron_1.app.commandLine.appendSwitch('ozone-platform', 'wayland');
}
// Load environment variables // Load environment variables
dotenv_1.default.config({ path: path_1.default.join(electron_1.app.getAppPath(), '..', '.env') }); dotenv_1.default.config({ path: path_1.default.join(electron_1.app.getAppPath(), '..', '.env') });
// --- App State --- // --- App State ---
@@ -65,66 +60,36 @@ const initBackend = () => {
}; };
// --- System Tray --- // --- System Tray ---
const createTray = () => { const createTray = () => {
console.log('[Tray] Initializing...'); const assetsDir = path_1.default.join(__dirname, '..', 'assets-build');
// 1. Determine source path (handling both Dev and Prod/ASAR) const possibleIcons = ['icon.svg', 'icon.png'];
let sourceIconPath = ''; let iconPath = '';
const possibleSourcePaths = [ for (const name of possibleIcons) {
path_1.default.join(electron_1.app.getAppPath(), 'assets-build', 'icon.png'), // Priority 1: ASAR/Internal const fullPath = path_1.default.join(assetsDir, name);
path_1.default.join(__dirname, '..', 'assets-build', 'icon.png'), // Priority 2: Dev if (fs_1.default.existsSync(fullPath)) {
path_1.default.join(process.resourcesPath, 'assets-build', 'icon.png') // Priority 3: External resources iconPath = fullPath;
];
for (const p of possibleSourcePaths) {
if (fs_1.default.existsSync(p)) {
sourceIconPath = p;
break; break;
} }
} }
if (!sourceIconPath) { console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
console.warn('[Tray] FAILED: No source icon found. Using empty fallback.'); if (!iconPath) {
try { console.warn(`[Tray] FAILED: No valid icon found in ${assetsDir}`);
tray = new electron_1.Tray(electron_1.nativeImage.createEmpty());
}
catch (e) { }
return; return;
} }
// 2. LINUX FIX: Extract icon from ASAR to real filesystem
let finalIconPath = sourceIconPath;
if (process.platform === 'linux') {
try {
const tempIconPath = path_1.default.join(electron_1.app.getPath('temp'), 'ultimate-ban-tracker-tray.png');
try {
fs_1.default.unlinkSync(tempIconPath);
}
catch (e) { }
console.log(`[Tray] Extracting icon to: ${tempIconPath}`);
fs_1.default.copyFileSync(sourceIconPath, tempIconPath);
finalIconPath = tempIconPath;
}
catch (e) {
console.error(`[Tray] Failed to extract icon: ${e.message}`);
}
}
try { try {
console.log(`[Tray] Creating tray with icon: ${finalIconPath}`); const icon = electron_1.nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
const icon = electron_1.nativeImage.createFromPath(finalIconPath).resize({ width: 16, height: 16 });
tray = new electron_1.Tray(icon); tray = new electron_1.Tray(icon);
tray.setToolTip('Ultimate Ban Tracker'); tray.setToolTip('Ultimate Ban Tracker');
if (process.platform === 'linux') {
tray.setIgnoreMouseEvents(false);
}
tray.on('click', () => { tray.on('click', () => {
if (mainWindow) { if (mainWindow) {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); mainWindow.show();
mainWindow.focus();
} }
}); });
updateTrayMenu(); updateTrayMenu();
const config = store.get('serverConfig');
if (config?.theme)
setAppIcon(config.theme);
console.log(`[Tray] Successfully initialized`); console.log(`[Tray] Successfully initialized`);
} }
catch (e) { catch (e) {
console.error(`[Tray] Error: ${e.message}`); console.error(`[Tray] Critical error during initialization: ${e.message}`);
} }
}; };
const updateTrayMenu = () => { const updateTrayMenu = () => {
@@ -141,34 +106,20 @@ const updateTrayMenu = () => {
label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`, label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`,
enabled: !!acc.loginName, enabled: !!acc.loginName,
click: () => handleSwitchAccount(acc.loginName) click: () => handleSwitchAccount(acc.loginName)
})) : [{ label: 'No accounts tracked', enabled: false }] })) : [{ label: 'No accounts found', enabled: false }]
},
{
label: 'Sync Now',
enabled: !!config?.enabled,
click: () => syncAccounts()
}, },
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
{ type: 'separator' }, { type: 'separator' },
{ label: 'Show Dashboard', click: () => { if (mainWindow) { { label: 'Show Dashboard', click: () => { if (mainWindow)
mainWindow.show(); mainWindow.show(); } },
mainWindow.focus(); { label: 'Quit', click: () => { electron_1.app.isQuitting = true; electron_1.app.quit(); } }
} } },
{ label: 'Quit', click: () => {
electron_1.app.isQuitting = true;
if (tray)
tray.destroy();
electron_1.app.quit();
} }
]); ]);
tray.setContextMenu(contextMenu); tray.setContextMenu(contextMenu);
}; };
const setAppIcon = (themeName = 'steam') => {
const assetsDir = path_1.default.join(electron_1.app.getAppPath(), 'assets-build', 'icons');
const iconPath = path_1.default.join(assetsDir, `${themeName}.svg`);
if (!fs_1.default.existsSync(iconPath))
return;
const icon = electron_1.nativeImage.createFromPath(iconPath);
if (tray)
tray.setImage(icon.resize({ width: 16, height: 16 }));
if (mainWindow)
mainWindow.setIcon(icon);
};
// --- Steam Logic --- // --- Steam Logic ---
const killSteam = async () => { const killSteam = async () => {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -184,23 +135,13 @@ const handleSwitchAccount = async (loginName) => {
if (!loginName) if (!loginName)
return false; return false;
try { try {
await killSteam();
const accounts = store.get('accounts'); const accounts = store.get('accounts');
const account = accounts.find(a => a.loginName === loginName); const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steam_client_1.steamClient.extractAccountConfig(loginName);
if (freshConfig) {
account.loginConfig = freshConfig;
account.sessionUpdatedAt = new Date().toISOString();
if (backend)
await backend.shareAccount(account);
store.set('accounts', accounts);
}
}
await killSteam();
if (process.platform === 'win32') { if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"'; const regCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`;
const commands = [`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`, `${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`, `${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`, `${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`]; const rememberCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v RememberPassword /t REG_DWORD /d 1 /f`;
await new Promise((res, rej) => (0, child_process_1.exec)(commands.join(' && '), (e) => e ? rej(e) : res())); await new Promise((res, rej) => (0, child_process_1.exec)(`${regCommand} && ${rememberCommand}`, (e) => e ? rej(e) : res()));
if (account && account.loginConfig) if (account && account.loginConfig)
steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig); steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig);
} }
@@ -214,56 +155,8 @@ const handleSwitchAccount = async (loginName) => {
return false; return false;
} }
}; };
// --- Scraper Helper ---
const scrapeAccountData = async (account) => {
const now = new Date();
try {
const profile = await (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName;
account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned;
account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath)
account.localAvatar = localPath;
}
if (account.steamLoginSecure) {
try {
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
account.authError = false;
account.lastScrapeTime = now.toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend)
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
}
else {
account.cooldownExpiresAt = undefined;
if (backend)
await backend.pushCooldown(account.steamId, undefined, now.toISOString());
}
}
catch (e) {
if (e instanceof scraper_1.SteamAuthError)
account.authError = true;
}
}
if (backend && !account._id.startsWith('shared_'))
await backend.shareAccount(account);
return true;
}
catch (e) {
return false;
}
};
// --- Sync Worker --- // --- Sync Worker ---
const syncAccounts = async (isManual = false) => { const syncAccounts = async () => {
console.log(`[Sync] Starting ${isManual ? 'MANUAL' : 'BACKGROUND'} phase 1 (Server Pull)...`);
initBackend(); initBackend();
let accounts = store.get('accounts'); let accounts = store.get('accounts');
let hasChanges = false; let hasChanges = false;
@@ -274,23 +167,19 @@ const syncAccounts = async (isManual = false) => {
const exists = accounts.find(a => a.steamId === s.steamId); const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) { if (!exists) {
accounts.push({ accounts.push({
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName, _id: `shared_${s.steamId}`,
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned, steamId: s.steamId, personaName: s.personaName, avatar: s.avatar, profileUrl: s.profileUrl,
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt, vacBanned: s.vacBanned, gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginConfig: s.loginConfig,
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt, sessionUpdatedAt: s.sessionUpdatedAt, autoCheckCooldown: !!s.steamLoginSecure,
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
}); });
hasChanges = true; hasChanges = true;
} }
else { else {
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0); const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0); const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
const isLocalAccount = !exists._id.startsWith('shared_'); if (sDate > lDate) {
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
if (shouldOverwriteCredentials) {
if (s.loginName) if (s.loginName)
exists.loginName = s.loginName; exists.loginName = s.loginName;
if (s.loginConfig) if (s.loginConfig)
@@ -303,34 +192,14 @@ const syncAccounts = async (isManual = false) => {
exists.sessionUpdatedAt = s.sessionUpdatedAt; exists.sessionUpdatedAt = s.sessionUpdatedAt;
hasChanges = true; hasChanges = true;
} }
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0); if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
if (sMetaDate > lMetaDate) {
exists.personaName = s.personaName;
exists.avatar = s.avatar;
exists.vacBanned = s.vacBanned;
exists.gameBans = s.gameBans;
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
exists.lastBanCheck = s.lastMetadataCheck;
hasChanges = true;
}
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
if (sScrapeDate > lScrapeDate) {
exists.cooldownExpiresAt = s.cooldownExpiresAt; exists.cooldownExpiresAt = s.cooldownExpiresAt;
exists.lastScrapeTime = s.lastScrapeTime;
hasChanges = true;
}
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) {
exists.sharedWith = s.sharedWith;
hasChanges = true; hasChanges = true;
} }
} }
} }
} }
catch (e) { catch (e) { }
console.error('[Sync] Pull failed:', e);
}
} }
if (hasChanges) { if (hasChanges) {
store.set('accounts', accounts); store.set('accounts', accounts);
@@ -338,79 +207,126 @@ const syncAccounts = async (isManual = false) => {
mainWindow.webContents.send('accounts-updated', accounts); mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu(); updateTrayMenu();
} }
const runScrapes = async () => { if (accounts.length === 0)
console.log(`[Sync] Starting phase 2 (Scrapes) for ${accounts.length} accounts...`); return;
const currentAccounts = [...store.get('accounts')]; const updatedAccounts = [...accounts];
let scrapeChanges = false; let scrapeChanges = false;
for (const account of currentAccounts) { for (const account of updatedAccounts) {
try { try {
const now = new Date(); const now = new Date();
if (backend && !account._id.startsWith('shared_')) const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
if ((now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName) {
const profile = await (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName;
account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned;
account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath)
account.localAvatar = localPath;
}
if (account.loginName) {
const config = steam_client_1.steamClient.extractAccountConfig(account.loginName);
if (config) {
account.loginConfig = config;
account.sessionUpdatedAt = new Date().toISOString();
}
}
if (backend)
await backend.shareAccount(account); await backend.shareAccount(account);
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0); scrapeChanges = true;
}
if (account.autoCheckCooldown && account.steamLoginSecure) {
if (account.cooldownExpiresAt && new Date(account.cooldownExpiresAt) > now)
continue;
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0); const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName; if ((now.getTime() - lastScrape.getTime()) / 3600000 > 8) {
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8; await new Promise(r => setTimeout(r, Math.floor(Math.random() * 60000) + 5000));
if (needsMetadata || needsCooldown || isManual) { try {
if (!isManual && needsCooldown) const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000)); account.authError = false;
if (await scrapeAccountData(account)) account.lastScrapeTime = new Date().toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend)
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt);
}
else if (account.cooldownExpiresAt) {
account.cooldownExpiresAt = undefined;
if (backend)
await backend.pushCooldown(account.steamId, undefined);
}
scrapeChanges = true; scrapeChanges = true;
}
catch (e) {
if (e.message.includes('cookie') || e.message.includes('Sign In')) {
account.authError = true;
scrapeChanges = true;
}
}
} }
} }
catch (error) { }
} }
if (scrapeChanges) { catch (error) { }
store.set('accounts', currentAccounts); }
if (mainWindow) if (scrapeChanges) {
mainWindow.webContents.send('accounts-updated', currentAccounts); store.set('accounts', updatedAccounts);
updateTrayMenu(); if (mainWindow)
} mainWindow.webContents.send('accounts-updated', updatedAccounts);
console.log('[Sync] All phases complete.'); updateTrayMenu();
}; }
if (isManual)
await runScrapes();
else
runScrapes();
}; };
const scheduleNextSync = () => { setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000); }; const scheduleNextSync = () => {
// --- Single Instance & Window --- setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
const gotTheLock = electron_1.app.requestSingleInstanceLock(); };
if (!gotTheLock) { // --- Discovery ---
electron_1.app.quit(); const addingAccounts = new Set();
} const handleLocalAccountsFound = async (localAccounts) => {
else { const currentAccounts = store.get('accounts');
electron_1.app.on('second-instance', () => { let hasChanges = false;
if (mainWindow) { for (const local of localAccounts) {
if (mainWindow.isMinimized()) if (addingAccounts.has(local.steamId))
mainWindow.restore(); continue;
mainWindow.show(); const exists = currentAccounts.find(a => a.steamId === local.steamId);
mainWindow.focus(); if (exists) {
if (!exists.loginName && local.accountName) {
exists.loginName = local.accountName;
hasChanges = true;
}
} }
}); else {
electron_1.app.whenReady().then(() => { addingAccounts.add(local.steamId);
electron_1.protocol.handle('steam-resource', (request) => {
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
if (process.platform !== 'win32' && !rawPath.startsWith('/'))
rawPath = '/' + rawPath;
const absolutePath = path_1.default.isAbsolute(rawPath) ? rawPath : path_1.default.resolve(rawPath);
if (!fs_1.default.existsSync(absolutePath))
return new Response('Not Found', { status: 404 });
try { try {
return electron_1.net.fetch((0, url_1.pathToFileURL)(absolutePath).toString()); const profile = await (0, steam_web_1.fetchProfileData)(local.steamId);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl);
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId, personaName: profile.personaName || local.accountName,
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
localAvatar: localPath, profileUrl: profile.profileUrl,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
});
hasChanges = true;
} }
catch (e) { catch (e) { }
return new Response('Error', { status: 500 }); addingAccounts.delete(local.steamId);
} }
}); }
createWindow(); if (hasChanges) {
createTray(); store.set('accounts', currentAccounts);
initBackend(); if (mainWindow)
setTimeout(() => syncAccounts(false), 5000); mainWindow.webContents.send('accounts-updated', currentAccounts);
scheduleNextSync(); updateTrayMenu();
steam_client_1.steamClient.startWatching(handleLocalAccountsFound); }
}); };
} // --- Main Window Creation ---
function createWindow() { function createWindow() {
mainWindow = new electron_1.BrowserWindow({ mainWindow = new electron_1.BrowserWindow({
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true, width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
@@ -418,7 +334,7 @@ function createWindow() {
}); });
mainWindow.setMenu(null); mainWindow.setMenu(null);
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
if (!isQuitting) { if (!electron_1.app.isQuitting) {
event.preventDefault(); event.preventDefault();
mainWindow?.hide(); mainWindow?.hide();
} }
@@ -429,30 +345,35 @@ function createWindow() {
else else
mainWindow.loadFile(path_1.default.join(__dirname, '..', 'dist', 'index.html')); mainWindow.loadFile(path_1.default.join(__dirname, '..', 'dist', 'index.html'));
} }
electron_1.app.on('before-quit', () => { // --- App Lifecycle ---
console.log('[App] Preparing to quit...'); electron_1.app.whenReady().then(() => {
electron_1.app.isQuitting = true; electron_1.protocol.handle('steam-resource', (request) => {
if (tray) let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
tray.destroy(); if (process.platform !== 'win32' && !rawPath.startsWith('/'))
rawPath = '/' + rawPath;
const absolutePath = path_1.default.isAbsolute(rawPath) ? rawPath : path_1.default.resolve(rawPath);
if (!fs_1.default.existsSync(absolutePath))
return new Response('Not Found', { status: 404 });
try {
return electron_1.net.fetch((0, url_1.pathToFileURL)(absolutePath).toString());
}
catch (e) {
return new Response('Error', { status: 500 });
}
});
createWindow();
createTray();
initBackend();
setTimeout(syncAccounts, 5000);
scheduleNextSync();
steam_client_1.steamClient.startWatching(handleLocalAccountsFound);
}); });
electron_1.app.on('window-all-closed', () => { if (process.platform !== 'darwin' && electron_1.app.isQuitting) electron_1.app.on('window-all-closed', () => { if (process.platform !== 'darwin' && electron_1.app.isQuitting)
electron_1.app.quit(); }); electron_1.app.quit(); });
electron_1.app.on('activate', () => { if (electron_1.BrowserWindow.getAllWindows().length === 0 && gotTheLock) { /* Handled */ } electron_1.app.on('activate', () => { if (electron_1.BrowserWindow.getAllWindows().length === 0)
createWindow();
else else
mainWindow?.show(); }); mainWindow?.show(); });
// Handle terminal termination (Ctrl+C / SIGTERM)
const handleSignal = (signal) => {
console.log(`[App] Received ${signal}`);
if (!electron_1.app.isQuitting && mainWindow) {
console.log(`[App] Hiding window instead of quitting due to ${signal}`);
mainWindow.hide();
}
else if (electron_1.app.isQuitting) {
electron_1.app.quit();
}
};
process.on('SIGINT', () => handleSignal('SIGINT'));
process.on('SIGTERM', () => handleSignal('SIGTERM'));
// --- IPC Handlers --- // --- IPC Handlers ---
electron_1.ipcMain.handle('get-accounts', () => store.get('accounts')); electron_1.ipcMain.handle('get-accounts', () => store.get('accounts'));
electron_1.ipcMain.handle('get-server-config', () => store.get('serverConfig')); electron_1.ipcMain.handle('get-server-config', () => store.get('serverConfig'));
@@ -469,7 +390,10 @@ electron_1.ipcMain.handle('login-to-server', async () => {
if (!config.url) if (!config.url)
return false; return false;
return new Promise((resolve) => { return new Promise((resolve) => {
const authWindow = new electron_1.BrowserWindow({ width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server', webPreferences: { nodeIntegration: false, contextIsolation: true } }); const authWindow = new electron_1.BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Ban Tracker Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`); authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false; let captured = false;
const saveServerAuth = (token) => { const saveServerAuth = (token) => {
@@ -477,15 +401,13 @@ electron_1.ipcMain.handle('login-to-server', async () => {
return; return;
captured = true; captured = true;
let serverSteamId = undefined; let serverSteamId = undefined;
let isAdmin = false;
try { try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
serverSteamId = payload.steamId; serverSteamId = payload.steamId;
isAdmin = !!payload.isAdmin;
} }
catch (e) { } catch (e) { }
const current = store.get('serverConfig'); const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true }); store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
initBackend(); initBackend();
authWindow.close(); authWindow.close();
resolve(true); resolve(true);
@@ -498,30 +420,18 @@ electron_1.ipcMain.handle('login-to-server', async () => {
saveServerAuth(authToken); saveServerAuth(authToken);
callback({ cancel: false }); callback({ cancel: false });
}); });
authWindow.on('page-title-updated', (event, title) => { if (title.includes('AUTH_TOKEN:')) { authWindow.on('page-title-updated', (event, title) => {
const token = title.split('AUTH_TOKEN:')[1]; if (title.includes('AUTH_TOKEN:')) {
if (token) const token = title.split('AUTH_TOKEN:')[1];
saveServerAuth(token); if (token)
} }); saveServerAuth(token);
}
});
authWindow.on('closed', () => resolve(false)); authWindow.on('closed', () => resolve(false));
}); });
}); });
electron_1.ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId })); electron_1.ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId }));
electron_1.ipcMain.handle('sync-now', async () => { await syncAccounts(true); return true; }); electron_1.ipcMain.handle('sync-now', async () => { await syncAccounts(); return true; });
electron_1.ipcMain.handle('scrape-account', async (event, steamId) => {
const accounts = store.get('accounts');
const account = accounts.find(a => a.steamId === steamId);
if (!account)
return false;
const success = await scrapeAccountData(account);
if (success) {
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
}
return success;
});
electron_1.ipcMain.handle('add-account', async (event, { identifier }) => { electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
try { try {
initBackend(); initBackend();
@@ -539,7 +449,7 @@ electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure, loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt, loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none', autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith lastBanCheck: new Date().toISOString()
}; };
store.set('accounts', [...accounts, newAccount]); store.set('accounts', [...accounts, newAccount]);
updateTrayMenu(); updateTrayMenu();
@@ -550,7 +460,12 @@ electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl); const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl);
const localAvatar = await downloadAvatar(profile.steamId, profile.avatar); const localAvatar = await downloadAvatar(profile.steamId, profile.avatar);
const accounts = store.get('accounts'); const accounts = store.get('accounts');
const newAccount = { _id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName, loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl, autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans, status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString() }; const newAccount = {
_id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName,
loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl,
autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]); store.set('accounts', [...accounts, newAccount]);
updateTrayMenu(); updateTrayMenu();
return newAccount; return newAccount;
@@ -587,63 +502,32 @@ electron_1.ipcMain.handle('share-account-with-user', async (event, steamId, targ
} }
throw new Error('Backend not configured'); throw new Error('Backend not configured');
}); });
electron_1.ipcMain.handle('revoke-account-access', async (event, steamId, targetSteamId) => { initBackend(); if (backend) electron_1.ipcMain.handle('revoke-account-access', async (event, steamId, targetSteamId) => {
return await backend.revokeAccess(steamId, targetSteamId); throw new Error('Backend not configured'); }); initBackend();
electron_1.ipcMain.handle('revoke-all-account-access', async (event, steamId) => { initBackend(); if (backend) if (backend)
return await backend.revokeAllAccess(steamId); throw new Error('Backend not configured'); }); return await backend.revokeAccess(steamId, targetSteamId);
throw new Error('Backend not configured');
});
electron_1.ipcMain.handle('revoke-all-account-access', async (event, steamId) => {
initBackend();
if (backend)
return await backend.revokeAllAccess(steamId);
throw new Error('Backend not configured');
});
electron_1.ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; }); electron_1.ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
electron_1.ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; }); electron_1.ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
electron_1.ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; }); electron_1.ipcMain.handle('switch-account', async (event, loginName) => await handleSwitchAccount(loginName));
electron_1.ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
electron_1.ipcMain.handle('admin-delete-user', async (event, userId) => { initBackend(); if (backend)
await backend.deleteUser(userId); return true; });
electron_1.ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
electron_1.ipcMain.handle('admin-remove-account', async (event, steamId) => { initBackend(); if (backend)
await backend.forceRemoveAccount(steamId); return true; });
electron_1.ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
electron_1.ipcMain.handle('update-app-icon', (event, themeName) => { setAppIcon(themeName); return true; });
electron_1.ipcMain.handle('switch-account', async (event, loginName) => {
if (!loginName)
return false;
try {
const accounts = store.get('accounts');
const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steam_client_1.steamClient.extractAccountConfig(loginName);
if (freshConfig) {
account.loginConfig = freshConfig;
account.sessionUpdatedAt = new Date().toISOString();
if (backend)
await backend.shareAccount(account);
store.set('accounts', accounts);
}
}
await killSteam();
if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
const commands = [`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`, `${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`, `${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`, `${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`];
await new Promise((res, rej) => (0, child_process_1.exec)(commands.join(' && '), (e) => e ? rej(e) : res()));
if (account && account.loginConfig)
steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig);
}
else if (process.platform === 'linux') {
await steam_client_1.steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam();
return true;
}
catch (e) {
return false;
}
});
electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url)); electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url));
electron_1.ipcMain.handle('open-steam-app-login', async () => { electron_1.ipcMain.handle('open-steam-app-login', async () => {
console.log('[SteamClient] Preparing for fresh login...');
await killSteam(); await killSteam();
if (process.platform === 'win32') { if (process.platform === 'win32') {
// Clear auto-login registry
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f'; const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
await new Promise((res) => (0, child_process_1.exec)(clearReg, () => res())); await new Promise((res) => (0, child_process_1.exec)(clearReg, () => res()));
} }
else if (process.platform === 'linux') { else if (process.platform === 'linux') {
// On Linux we can use the steamClient helper to set an empty user
await steam_client_1.steamClient.setAutoLoginUser("", undefined, ""); await steam_client_1.steamClient.setAutoLoginUser("", undefined, "");
} }
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login'; const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
@@ -651,20 +535,34 @@ electron_1.ipcMain.handle('open-steam-app-login', async () => {
return true; return true;
}); });
electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => { electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => {
// Use a unique partition per account to prevent session bleeding
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new'; const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
const loginSession = electron_1.session.fromPartition(partitionId); const loginSession = electron_1.session.fromPartition(partitionId);
if (!expectedSteamId) // If adding a brand new account, explicitly clear previous trash
if (!expectedSteamId) {
console.log('[Auth] Clearing session for new account login...');
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] }); await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
}
// If we have an existing cookie string for this account, pre-inject it
if (expectedSteamId) { if (expectedSteamId) {
const accounts = store.get('accounts'); const accounts = store.get('accounts');
const account = accounts.find(a => a.steamId === expectedSteamId); const account = accounts.find(a => a.steamId === expectedSteamId);
if (account?.steamLoginSecure) { if (account?.steamLoginSecure) {
console.log(`[Auth] Pre-injecting existing cookies for ${account.personaName}...`);
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim()); const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
for (const pair of cookiePairs) { for (const pair of cookiePairs) {
const [name, value] = pair.split('='); const [name, value] = pair.split('=');
if (name && value) { if (name && value) {
try { try {
await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') }); await loginSession.cookies.set({
url: 'https://steamcommunity.com',
domain: 'steamcommunity.com',
name: name,
value: value,
path: '/',
secure: true,
httpOnly: name.includes('Secure')
});
} }
catch (e) { } catch (e) { }
} }
@@ -672,7 +570,10 @@ electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) =>
} }
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const loginWindow = new electron_1.BrowserWindow({ width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam', webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId } }); const loginWindow = new electron_1.BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
});
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730'); loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
const checkCookie = setInterval(async () => { const checkCookie = setInterval(async () => {
try { try {

View File

@@ -11,7 +11,6 @@ electron_1.contextBridge.exposeInMainWorld('electronAPI', {
revokeAccountAccess: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId), revokeAccountAccess: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
revokeAllAccountAccess: (steamId) => electron_1.ipcRenderer.invoke('revoke-all-account-access', steamId), revokeAllAccountAccess: (steamId) => electron_1.ipcRenderer.invoke('revoke-all-account-access', steamId),
openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', url), openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', url),
updateAppIcon: (theme) => electron_1.ipcRenderer.invoke('update-app-icon', theme),
openSteamAppLogin: () => electron_1.ipcRenderer.invoke('open-steam-app-login'), openSteamAppLogin: () => electron_1.ipcRenderer.invoke('open-steam-app-login'),
openSteamLogin: (steamId) => electron_1.ipcRenderer.invoke('open-steam-login', steamId), openSteamLogin: (steamId) => electron_1.ipcRenderer.invoke('open-steam-login', steamId),
// Server Config & Auth // Server Config & Auth
@@ -20,15 +19,8 @@ electron_1.contextBridge.exposeInMainWorld('electronAPI', {
loginToServer: () => electron_1.ipcRenderer.invoke('login-to-server'), loginToServer: () => electron_1.ipcRenderer.invoke('login-to-server'),
getServerUserInfo: () => electron_1.ipcRenderer.invoke('get-server-user-info'), getServerUserInfo: () => electron_1.ipcRenderer.invoke('get-server-user-info'),
syncNow: () => electron_1.ipcRenderer.invoke('sync-now'), syncNow: () => electron_1.ipcRenderer.invoke('sync-now'),
scrapeAccount: (steamId) => electron_1.ipcRenderer.invoke('scrape-account', steamId),
getCommunityAccounts: () => electron_1.ipcRenderer.invoke('get-community-accounts'), getCommunityAccounts: () => electron_1.ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => electron_1.ipcRenderer.invoke('get-server-users'), getServerUsers: () => electron_1.ipcRenderer.invoke('get-server-users'),
// Admin API
adminGetStats: () => electron_1.ipcRenderer.invoke('admin-get-stats'),
adminGetUsers: () => electron_1.ipcRenderer.invoke('admin-get-users'),
adminDeleteUser: (userId) => electron_1.ipcRenderer.invoke('admin-delete-user', userId),
adminGetAccounts: () => electron_1.ipcRenderer.invoke('admin-get-accounts'),
adminRemoveAccount: (steamId) => electron_1.ipcRenderer.invoke('admin-remove-account', steamId),
onAccountsUpdated: (callback) => { onAccountsUpdated: (callback) => {
const subscription = (_event, accounts) => callback(accounts); const subscription = (_event, accounts) => callback(accounts);
electron_1.ipcRenderer.on('accounts-updated', subscription); electron_1.ipcRenderer.on('accounts-updated', subscription);

View File

@@ -68,23 +68,19 @@ class BackendService {
loginName: account.loginName, loginName: account.loginName,
steamLoginSecure: account.steamLoginSecure, steamLoginSecure: account.steamLoginSecure,
loginConfig: account.loginConfig, loginConfig: account.loginConfig,
sessionUpdatedAt: account.sessionUpdatedAt, sessionUpdatedAt: account.sessionUpdatedAt
lastMetadataCheck: account.lastBanCheck,
lastScrapeTime: account.lastScrapeTime,
cooldownExpiresAt: account.cooldownExpiresAt
}, { headers: this.headers }); }, { headers: this.headers });
} }
catch (e) { catch (e) {
console.error('[Backend] Failed to share account'); console.error('[Backend] Failed to share account');
} }
} }
async pushCooldown(steamId, cooldownExpiresAt, lastScrapeTime) { async pushCooldown(steamId, cooldownExpiresAt) {
if (!this.token) if (!this.token)
return; return;
try { try {
await axios_1.default.patch(`${this.url}/api/sync/${steamId}/cooldown`, { await axios_1.default.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt, cooldownExpiresAt
lastScrapeTime
}, { headers: this.headers }); }, { headers: this.headers });
} }
catch (e) { catch (e) {
@@ -134,59 +130,5 @@ class BackendService {
throw new Error(e.response?.data?.message || 'Failed to revoke all access'); throw new Error(e.response?.data?.message || 'Failed to revoke all access');
} }
} }
// --- Admin API ---
async getAdminStats() {
if (!this.token)
return null;
try {
const response = await axios_1.default.get(`${this.url}/api/admin/stats`, { headers: this.headers });
return response.data;
}
catch (e) {
return null;
}
}
async getAdminUsers() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/admin/users`, { headers: this.headers });
return response.data;
}
catch (e) {
return [];
}
}
async deleteUser(userId) {
if (!this.token)
return;
try {
await axios_1.default.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
}
catch (e) {
throw new Error(e.response?.data?.message || 'Failed to delete user');
}
}
async getAdminAccounts() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
return response.data;
}
catch (e) {
return [];
}
}
async forceRemoveAccount(steamId) {
if (!this.token)
return;
try {
await axios_1.default.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
}
catch (e) {
throw new Error(e.response?.data?.message || 'Failed to remove account');
}
}
} }
exports.BackendService = BackendService; exports.BackendService = BackendService;

View File

@@ -36,17 +36,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.scrapeCooldown = exports.SteamAuthError = void 0; exports.scrapeCooldown = void 0;
const axios_1 = __importDefault(require("axios")); const axios_1 = __importDefault(require("axios"));
const cheerio = __importStar(require("cheerio")); const cheerio = __importStar(require("cheerio"));
// Custom error to identify session death
class SteamAuthError extends Error {
constructor(message) {
super(message);
this.name = "SteamAuthError";
}
}
exports.SteamAuthError = SteamAuthError;
const scrapeCooldown = async (steamId, steamLoginSecure) => { const scrapeCooldown = async (steamId, steamLoginSecure) => {
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`; const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
try { try {
@@ -55,38 +47,35 @@ const scrapeCooldown = async (steamId, steamLoginSecure) => {
'Cookie': steamLoginSecure, 'Cookie': steamLoginSecure,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}, },
timeout: 10000, timeout: 10000
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
}); });
// If Steam redirects us to the login page, the cookie is dead
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
}
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
if (!response.data.includes('Personal Game Data')) { if (response.data.includes('Sign In') || !response.data.includes('Personal Game Data')) {
throw new SteamAuthError('Session invalid: Personal Game Data not accessible'); throw new Error('Invalid or expired steamLoginSecure cookie');
} }
// 1. Locate the specific table containing cooldown info
let expirationDate = undefined; let expirationDate = undefined;
$('table').each((_, table) => { $('table').each((_, table) => {
const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get(); const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get();
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration')); const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration'));
if (expirationIndex !== -1) { if (expirationIndex !== -1) {
const rows = $(table).find('tr').not(':has(th)'); const firstRow = $(table).find('tr').not(':has(th)').first();
rows.each((_, row) => { const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
const dateText = $(row).find('td').eq(expirationIndex).text().trim(); if (dateText && dateText !== '') {
if (dateText && dateText !== '') { const cleanDateText = dateText.replace(' GMT', ' UTC');
const cleanDateText = dateText.replace(' GMT', ' UTC'); const parsed = new Date(cleanDateText);
const parsed = new Date(cleanDateText); if (!isNaN(parsed.getTime())) {
if (!isNaN(parsed.getTime())) { expirationDate = parsed;
if (!expirationDate || parsed > expirationDate)
expirationDate = parsed;
}
} }
}); }
} }
}); });
if (expirationDate && expirationDate.getTime() > Date.now()) { if (expirationDate && expirationDate.getTime() > Date.now()) {
return { isActive: true, expiresAt: expirationDate }; console.log(`[Scraper] Found active cooldown until: ${expirationDate.toISOString()}`);
return {
isActive: true,
expiresAt: expirationDate
};
} }
const content = $('#personal_game_data_content').text(); const content = $('#personal_game_data_content').text();
if (content.includes('Competitive Cooldown') || content.includes('Your account is currently')) { if (content.includes('Competitive Cooldown') || content.includes('Your account is currently')) {
@@ -95,10 +84,8 @@ const scrapeCooldown = async (steamId, steamLoginSecure) => {
return { isActive: false }; return { isActive: false };
} }
catch (error) { catch (error) {
if (error instanceof SteamAuthError) console.error(`[Scraper] Error for ${steamId}:`, error.message);
throw error; throw error;
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message);
throw error; // Generic errors don't trigger re-auth
} }
}; };
exports.scrapeCooldown = scrapeCooldown; exports.scrapeCooldown = scrapeCooldown;

View File

@@ -21,8 +21,7 @@ class SteamClientService {
if (platform === 'win32') { if (platform === 'win32') {
const possiblePaths = [ const possiblePaths = [
'C:\\Program Files (x86)\\Steam', 'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam', 'C:\\Program Files\\Steam'
path_1.default.join(process.env.APPDATA || '', 'Steam'),
]; ];
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null; this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
} }
@@ -30,8 +29,7 @@ class SteamClientService {
const possiblePaths = [ const possiblePaths = [
path_1.default.join(home, '.steam/steam'), path_1.default.join(home, '.steam/steam'),
path_1.default.join(home, '.local/share/Steam'), path_1.default.join(home, '.local/share/Steam'),
path_1.default.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak path_1.default.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam')
path_1.default.join(home, 'snap/steam/common/.steam/steam'), // Snap
]; ];
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null; this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
} }
@@ -49,29 +47,12 @@ class SteamClientService {
return null; return null;
return path_1.default.join(this.steamPath, 'config', 'config.vdf'); return path_1.default.join(this.steamPath, 'config', 'config.vdf');
} }
safeWriteVdf(filePath, data) {
const tempPath = `${filePath}.tmp_${Date.now()}`;
const dir = path_1.default.dirname(filePath);
try {
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
const vdfContent = (0, simple_vdf_1.stringify)(data);
fs_1.default.writeFileSync(tempPath, vdfContent, 'utf-8');
fs_1.default.renameSync(tempPath, filePath);
}
catch (e) {
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
if (fs_1.default.existsSync(tempPath))
fs_1.default.unlinkSync(tempPath);
throw e;
}
}
startWatching(callback) { startWatching(callback) {
this.onAccountsChanged = callback; this.onAccountsChanged = callback;
const loginUsersPath = this.getLoginUsersPath(); const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath && fs_1.default.existsSync(loginUsersPath)) { if (loginUsersPath && fs_1.default.existsSync(loginUsersPath)) {
this.readLocalAccounts(); this.readLocalAccounts();
chokidar_1.default.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => { chokidar_1.default.watch(loginUsersPath, { persistent: true }).on('change', () => {
this.readLocalAccounts(); this.readLocalAccounts();
}); });
} }
@@ -82,26 +63,25 @@ class SteamClientService {
return; return;
try { try {
const content = fs_1.default.readFileSync(filePath, 'utf-8'); const content = fs_1.default.readFileSync(filePath, 'utf-8');
if (!content.trim())
return;
const data = (0, simple_vdf_1.parse)(content); const data = (0, simple_vdf_1.parse)(content);
if (!data || !data.users) if (!data || !data.users)
return; return;
const accounts = []; const accounts = [];
for (const [steamId64, userData] of Object.entries(data.users)) { for (const [steamId64, userData] of Object.entries(data.users)) {
const user = userData; const user = userData;
if (!user || !user.AccountName)
continue;
accounts.push({ accounts.push({
steamId: steamId64, accountName: user.AccountName, steamId: steamId64,
personaName: user.PersonaName || user.AccountName, accountName: user.AccountName,
personaName: user.PersonaName,
timestamp: parseInt(user.Timestamp) || 0 timestamp: parseInt(user.Timestamp) || 0
}); });
} }
if (this.onAccountsChanged) if (this.onAccountsChanged)
this.onAccountsChanged(accounts); this.onAccountsChanged(accounts);
} }
catch (error) { } catch (error) {
console.error('[SteamClient] Error parsing loginusers.vdf:', error);
}
} }
extractAccountConfig(accountName) { extractAccountConfig(accountName) {
const configPath = this.getConfigVdfPath(); const configPath = this.getConfigVdfPath();
@@ -111,60 +91,63 @@ class SteamClientService {
const content = fs_1.default.readFileSync(configPath, 'utf-8'); const content = fs_1.default.readFileSync(configPath, 'utf-8');
const data = (0, simple_vdf_1.parse)(content); const data = (0, simple_vdf_1.parse)(content);
const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts; const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts;
return (accounts && accounts[accountName]) ? accounts[accountName] : null; if (accounts && accounts[accountName]) {
return accounts[accountName];
}
} }
catch (e) { catch (e) {
return null; console.error('[SteamClient] Failed to extract config.vdf data');
} }
return null;
} }
injectAccountConfig(accountName, accountData) { injectAccountConfig(accountName, accountData) {
const configPath = this.getConfigVdfPath(); const configPath = this.getConfigVdfPath();
if (!configPath) if (!configPath)
return; return;
// Create directory if it doesn't exist
const configDir = path_1.default.dirname(configPath);
if (!fs_1.default.existsSync(configDir))
fs_1.default.mkdirSync(configDir, { recursive: true });
let data = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } }; let data = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } };
if (fs_1.default.existsSync(configPath)) { if (fs_1.default.existsSync(configPath)) {
try { try {
const content = fs_1.default.readFileSync(configPath, 'utf-8'); const content = fs_1.default.readFileSync(configPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content); data = (0, simple_vdf_1.parse)(content);
if (parsed && typeof parsed === 'object')
data = parsed;
} }
catch (e) { } catch (e) { }
} }
const ensurePath = (obj, keys) => { // Ensure structure exists
let curr = obj; if (!data.InstallConfigStore)
for (const key of keys) { data.InstallConfigStore = {};
if (!curr[key] || typeof curr[key] !== 'object') if (!data.InstallConfigStore.Software)
curr[key] = {}; data.InstallConfigStore.Software = {};
curr = curr[key]; if (!data.InstallConfigStore.Software.Valve)
} data.InstallConfigStore.Software.Valve = {};
return curr; if (!data.InstallConfigStore.Software.Valve.Steam)
}; data.InstallConfigStore.Software.Valve.Steam = {};
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']); if (!data.InstallConfigStore.Software.Valve.Steam.Accounts)
// FAILPROOF: Force crucial flags that Steam uses to decide session validity data.InstallConfigStore.Software.Valve.Steam.Accounts = {};
steamAccounts[accountName] = { data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
...accountData,
RememberPassword: "1",
AllowAutoLogin: "1",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
try { try {
this.safeWriteVdf(configPath, data); fs_1.default.writeFileSync(configPath, (0, simple_vdf_1.stringify)(data));
console.log(`[SteamClient] Safely injected session for ${accountName}`); console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`);
}
catch (e) {
console.error('[SteamClient] Failed to write config.vdf');
} }
catch (e) { }
} }
async setAutoLoginUser(accountName, accountConfig, steamId) { async setAutoLoginUser(accountName, accountConfig, steamId) {
const platform = os_1.default.platform(); const platform = os_1.default.platform();
const loginUsersPath = this.getLoginUsersPath(); const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath) { if (loginUsersPath) {
const configDir = path_1.default.dirname(loginUsersPath);
if (!fs_1.default.existsSync(configDir))
fs_1.default.mkdirSync(configDir, { recursive: true });
let data = { users: {} }; let data = { users: {} };
if (fs_1.default.existsSync(loginUsersPath)) { if (fs_1.default.existsSync(loginUsersPath)) {
try { try {
const content = fs_1.default.readFileSync(loginUsersPath, 'utf-8'); const content = fs_1.default.readFileSync(loginUsersPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content); data = (0, simple_vdf_1.parse)(content);
if (parsed && parsed.users)
data = parsed;
} }
catch (e) { } catch (e) { }
} }
@@ -173,7 +156,7 @@ class SteamClientService {
let found = false; let found = false;
for (const [id, user] of Object.entries(data.users)) { for (const [id, user] of Object.entries(data.users)) {
const u = user; const u = user;
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) { if (u.AccountName.toLowerCase() === accountName.toLowerCase()) {
u.mostrecent = "1"; u.mostrecent = "1";
u.RememberPassword = "1"; u.RememberPassword = "1";
u.AllowAutoLogin = "1"; u.AllowAutoLogin = "1";
@@ -186,8 +169,8 @@ class SteamClientService {
u.mostrecent = "0"; u.mostrecent = "0";
} }
} }
if (!found && steamId && accountName) { if (!found && steamId) {
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`); console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
data.users[steamId] = { data.users[steamId] = {
AccountName: accountName, AccountName: accountName,
PersonaName: accountName, PersonaName: accountName,
@@ -201,49 +184,51 @@ class SteamClientService {
}; };
} }
try { try {
this.safeWriteVdf(loginUsersPath, data); fs_1.default.writeFileSync(loginUsersPath, (0, simple_vdf_1.stringify)(data));
}
catch (e) {
console.error('[SteamClient] Failed to write loginusers.vdf');
} }
catch (e) { }
} }
// Injection of the actual authentication blob if (accountConfig) {
if (accountConfig && accountName) {
this.injectAccountConfig(accountName, accountConfig); this.injectAccountConfig(accountName, accountConfig);
} }
// --- Linux Registry / Registry.vdf Hardening ---
if (platform === 'linux') { if (platform === 'linux') {
const regLocations = [ const regLocations = [
path_1.default.join(os_1.default.homedir(), '.steam', 'registry.vdf'), path_1.default.join(os_1.default.homedir(), '.steam', 'registry.vdf'),
path_1.default.join(os_1.default.homedir(), '.steam', 'steam', 'registry.vdf') path_1.default.join(os_1.default.homedir(), '.steam', 'steam', 'registry.vdf')
]; ];
for (const regPath of regLocations) { for (const regPath of regLocations) {
if (!fs_1.default.existsSync(path_1.default.dirname(regPath))) let regData = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
continue;
let regData = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
if (fs_1.default.existsSync(regPath)) { if (fs_1.default.existsSync(regPath)) {
try { try {
const content = fs_1.default.readFileSync(regPath, 'utf-8'); const content = fs_1.default.readFileSync(regPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content); regData = (0, simple_vdf_1.parse)(content);
if (parsed && typeof parsed === 'object')
regData = parsed;
} }
catch (e) { } catch (e) { }
} }
const ensurePath = (obj, keys) => { else {
const regDir = path_1.default.dirname(regPath);
if (!fs_1.default.existsSync(regDir))
fs_1.default.mkdirSync(regDir, { recursive: true });
}
const setPath = (obj, keys, val) => {
let curr = obj; let curr = obj;
for (const key of keys) { for (let i = 0; i < keys.length - 1; i++) {
if (!curr[key] || typeof curr[key] !== 'object') if (!curr[keys[i]])
curr[key] = {}; curr[keys[i]] = {};
curr = curr[key]; curr = curr[keys[i]];
} }
return curr; curr[keys[keys.length - 1]] = val;
}; };
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']); const steamReg = ['Registry', 'HKCU', 'Software', 'Valve', 'Steam'];
steamKey.AutoLoginUser = accountName; setPath(regData, [...steamReg, 'AutoLoginUser'], accountName);
steamKey.RememberPassword = "1"; setPath(regData, [...steamReg, 'RememberPassword'], "1");
steamKey.AlreadyLoggedIn = "1"; setPath(regData, [...steamReg, 'AlreadyLoggedIn'], "1");
steamKey.WantsOfflineMode = "0"; setPath(regData, [...steamReg, 'WantsOfflineMode'], "0");
try { try {
this.safeWriteVdf(regPath, regData); fs_1.default.writeFileSync(regPath, (0, simple_vdf_1.stringify)(regData));
console.log(`[SteamClient] Registry updated: ${regPath}`);
} }
catch (e) { } catch (e) { }
} }

View File

@@ -7,7 +7,7 @@ import axios from 'axios';
import fs from 'fs'; import fs from 'fs';
import { pathToFileURL } from 'url'; import { pathToFileURL } from 'url';
import { fetchProfileData, scrapeBanStatus } from './services/steam-web'; import { fetchProfileData, scrapeBanStatus } from './services/steam-web';
import { scrapeCooldown, SteamAuthError } from './services/scraper'; import { scrapeCooldown } from './services/scraper';
import { steamClient, LocalSteamAccount } from './services/steam-client'; import { steamClient, LocalSteamAccount } from './services/steam-client';
import { BackendService } from './services/backend'; import { BackendService } from './services/backend';
@@ -16,12 +16,6 @@ const isDev = !app.isPackaged;
app.name = "Ultimate Ban Tracker"; app.name = "Ultimate Ban Tracker";
// Force Wayland/Ozone support if on Linux
if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'UseOzonePlatform');
app.commandLine.appendSwitch('ozone-platform', 'wayland');
}
// Load environment variables // Load environment variables
dotenv.config({ path: path.join(app.getAppPath(), '..', '.env') }); dotenv.config({ path: path.join(app.getAppPath(), '..', '.env') });
@@ -46,7 +40,6 @@ interface Account {
cooldownExpiresAt?: string; cooldownExpiresAt?: string;
authError?: boolean; authError?: boolean;
notes?: string; notes?: string;
sharedWith?: any[];
} }
interface ServerConfig { interface ServerConfig {
@@ -55,7 +48,6 @@ interface ServerConfig {
serverSteamId?: string; serverSteamId?: string;
enabled: boolean; enabled: boolean;
theme?: string; theme?: string;
isAdmin?: boolean;
} }
// --- App State --- // --- App State ---
@@ -82,7 +74,9 @@ const downloadAvatar = async (steamId: string, url: string): Promise<string | un
const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 5000 }); const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 5000 });
fs.writeFileSync(localPath, Buffer.from(response.data)); fs.writeFileSync(localPath, Buffer.from(response.data));
return localPath; return localPath;
} catch (e) { return undefined; } } catch (e) {
return undefined;
}
}; };
// --- Backend --- // --- Backend ---
@@ -90,70 +84,46 @@ const initBackend = () => {
const config = store.get('serverConfig'); const config = store.get('serverConfig');
if (config && config.enabled && config.url) { if (config && config.enabled && config.url) {
backend = new BackendService(config.url, config.token); backend = new BackendService(config.url, config.token);
} else { backend = null; } } else {
backend = null;
}
}; };
// --- System Tray --- // --- System Tray ---
const createTray = () => { const createTray = () => {
console.log('[Tray] Initializing...'); const assetsDir = path.join(__dirname, '..', 'assets-build');
const possibleIcons = ['icon.svg', 'icon.png'];
let iconPath = '';
// 1. Determine source path (handling both Dev and Prod/ASAR) for (const name of possibleIcons) {
let sourceIconPath = ''; const fullPath = path.join(assetsDir, name);
const possibleSourcePaths = [ if (fs.existsSync(fullPath)) {
path.join(app.getAppPath(), 'assets-build', 'icon.png'), // Priority 1: ASAR/Internal iconPath = fullPath;
path.join(__dirname, '..', 'assets-build', 'icon.png'), // Priority 2: Dev break;
path.join(process.resourcesPath, 'assets-build', 'icon.png') // Priority 3: External resources }
];
for (const p of possibleSourcePaths) {
if (fs.existsSync(p)) { sourceIconPath = p; break; }
} }
if (!sourceIconPath) { console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
console.warn('[Tray] FAILED: No source icon found. Using empty fallback.');
try { tray = new Tray(nativeImage.createEmpty()); } catch (e) {} if (!iconPath) {
console.warn(`[Tray] FAILED: No valid icon found in ${assetsDir}`);
return; return;
} }
// 2. LINUX FIX: Extract icon from ASAR to real filesystem
let finalIconPath = sourceIconPath;
if (process.platform === 'linux') {
try {
const tempIconPath = path.join(app.getPath('temp'), 'ultimate-ban-tracker-tray.png');
try { fs.unlinkSync(tempIconPath); } catch (e) {}
console.log(`[Tray] Extracting icon to: ${tempIconPath}`);
fs.copyFileSync(sourceIconPath, tempIconPath);
finalIconPath = tempIconPath;
} catch (e: any) {
console.error(`[Tray] Failed to extract icon: ${e.message}`);
}
}
try { try {
console.log(`[Tray] Creating tray with icon: ${finalIconPath}`); const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
const icon = nativeImage.createFromPath(finalIconPath).resize({ width: 16, height: 16 });
tray = new Tray(icon); tray = new Tray(icon);
tray.setToolTip('Ultimate Ban Tracker'); tray.setToolTip('Ultimate Ban Tracker');
if (process.platform === 'linux') {
tray.setIgnoreMouseEvents(false);
}
tray.on('click', () => { tray.on('click', () => {
if (mainWindow) { if (mainWindow) {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); mainWindow.show();
mainWindow.focus();
} }
}); });
updateTrayMenu(); updateTrayMenu();
const config = store.get('serverConfig');
if (config?.theme) setAppIcon(config.theme);
console.log(`[Tray] Successfully initialized`); console.log(`[Tray] Successfully initialized`);
} catch (e: any) { } catch (e: any) {
console.error(`[Tray] Error: ${e.message}`); console.error(`[Tray] Critical error during initialization: ${e.message}`);
} }
}; };
@@ -171,31 +141,21 @@ const updateTrayMenu = () => {
label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`, label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`,
enabled: !!acc.loginName, enabled: !!acc.loginName,
click: () => handleSwitchAccount(acc.loginName) click: () => handleSwitchAccount(acc.loginName)
})) : [{ label: 'No accounts tracked', enabled: false }] })) : [{ label: 'No accounts found', enabled: false }]
},
{
label: 'Sync Now',
enabled: !!config?.enabled,
click: () => syncAccounts()
}, },
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
{ type: 'separator' }, { type: 'separator' },
{ label: 'Show Dashboard', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, { label: 'Show Dashboard', click: () => { if (mainWindow) mainWindow.show(); } },
{ label: 'Quit', click: () => { { label: 'Quit', click: () => { (app as any).isQuitting = true; app.quit(); } }
(app as any).isQuitting = true;
if (tray) tray.destroy();
app.quit();
} }
]); ]);
tray.setContextMenu(contextMenu); tray.setContextMenu(contextMenu);
}; };
const setAppIcon = (themeName: string = 'steam') => {
const assetsDir = path.join(app.getAppPath(), 'assets-build', 'icons');
const iconPath = path.join(assetsDir, `${themeName}.svg`);
if (!fs.existsSync(iconPath)) return;
const icon = nativeImage.createFromPath(iconPath);
if (tray) tray.setImage(icon.resize({ width: 16, height: 16 }));
if (mainWindow) mainWindow.setIcon(icon);
};
// --- Steam Logic --- // --- Steam Logic ---
const killSteam = async () => { const killSteam = async () => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
@@ -212,68 +172,28 @@ const startSteam = () => {
const handleSwitchAccount = async (loginName: string) => { const handleSwitchAccount = async (loginName: string) => {
if (!loginName) return false; if (!loginName) return false;
try { try {
await killSteam();
const accounts = store.get('accounts') as Account[]; const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.loginName === loginName); const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steamClient.extractAccountConfig(loginName);
if (freshConfig) {
account.loginConfig = freshConfig;
account.sessionUpdatedAt = new Date().toISOString();
if (backend) await backend.shareAccount(account);
store.set('accounts', accounts);
}
}
await killSteam();
if (process.platform === 'win32') { if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"'; const regCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`;
const commands = [`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`]; const rememberCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v RememberPassword /t REG_DWORD /d 1 /f`;
await new Promise<void>((res, rej) => exec(commands.join(' && '), (e) => e ? rej(e) : res())); await new Promise<void>((res, rej) => exec(`${regCommand} && ${rememberCommand}`, (e) => e ? rej(e) : res()));
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig); if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
} else if (process.platform === 'linux') { await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId); } } else if (process.platform === 'linux') {
await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam(); startSteam();
return true; return true;
} catch (e) { return false; } } catch (e) { return false; }
}; };
// --- Scraper Helper ---
const scrapeAccountData = async (account: Account) => {
const now = new Date();
try {
const profile = await fetchProfileData(account.steamId, account.steamLoginSecure);
const bans = await scrapeBanStatus(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName; account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned; account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath) account.localAvatar = localPath;
}
if (account.steamLoginSecure) {
try {
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
account.authError = false; account.lastScrapeTime = now.toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
} else {
account.cooldownExpiresAt = undefined;
if (backend) await backend.pushCooldown(account.steamId, undefined, now.toISOString());
}
} catch (e: any) { if (e instanceof SteamAuthError) account.authError = true; }
}
if (backend && !account._id.startsWith('shared_')) await backend.shareAccount(account);
return true;
} catch (e) { return false; }
};
// --- Sync Worker --- // --- Sync Worker ---
const syncAccounts = async (isManual = false) => { const syncAccounts = async () => {
console.log(`[Sync] Starting ${isManual ? 'MANUAL' : 'BACKGROUND'} phase 1 (Server Pull)...`);
initBackend(); initBackend();
let accounts = store.get('accounts') as Account[]; let accounts = store.get('accounts') as Account[];
let hasChanges = false; let hasChanges = false;
if (backend) { if (backend) {
try { try {
const shared = await backend.getSharedAccounts(); const shared = await backend.getSharedAccounts();
@@ -281,108 +201,140 @@ const syncAccounts = async (isManual = false) => {
const exists = accounts.find(a => a.steamId === s.steamId); const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) { if (!exists) {
accounts.push({ accounts.push({
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName, _id: `shared_${s.steamId}`,
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned, steamId: s.steamId, personaName: s.personaName, avatar: s.avatar, profileUrl: s.profileUrl,
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt, vacBanned: s.vacBanned, gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginConfig: s.loginConfig,
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt, sessionUpdatedAt: s.sessionUpdatedAt, autoCheckCooldown: !!s.steamLoginSecure,
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
}); });
hasChanges = true; hasChanges = true;
} else { } else {
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0); const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0); const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
const isLocalAccount = !exists._id.startsWith('shared_'); if (sDate > lDate) {
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
if (shouldOverwriteCredentials) {
if (s.loginName) exists.loginName = s.loginName; if (s.loginName) exists.loginName = s.loginName;
if (s.loginConfig) exists.loginConfig = s.loginConfig; if (s.loginConfig) exists.loginConfig = s.loginConfig;
if (s.steamLoginSecure) { exists.steamLoginSecure = s.steamLoginSecure; exists.autoCheckCooldown = true; exists.authError = false; } if (s.steamLoginSecure) { exists.steamLoginSecure = s.steamLoginSecure; exists.autoCheckCooldown = true; exists.authError = false; }
exists.sessionUpdatedAt = s.sessionUpdatedAt; exists.sessionUpdatedAt = s.sessionUpdatedAt;
hasChanges = true; hasChanges = true;
} }
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0); if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
if (sMetaDate > lMetaDate) {
exists.personaName = s.personaName; exists.avatar = s.avatar;
exists.vacBanned = s.vacBanned; exists.gameBans = s.gameBans;
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
exists.lastBanCheck = s.lastMetadataCheck;
hasChanges = true;
}
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
if (sScrapeDate > lScrapeDate) {
exists.cooldownExpiresAt = s.cooldownExpiresAt; exists.cooldownExpiresAt = s.cooldownExpiresAt;
exists.lastScrapeTime = s.lastScrapeTime;
hasChanges = true; hasChanges = true;
} }
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) { exists.sharedWith = s.sharedWith; hasChanges = true; }
} }
} }
} catch (e) { console.error('[Sync] Pull failed:', e); } } catch (e) { }
} }
if (hasChanges) { store.set('accounts', accounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts); updateTrayMenu(); }
const runScrapes = async () => { if (hasChanges) {
console.log(`[Sync] Starting phase 2 (Scrapes) for ${accounts.length} accounts...`); store.set('accounts', accounts);
const currentAccounts = [...store.get('accounts') as Account[]]; if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
let scrapeChanges = false; updateTrayMenu();
for (const account of currentAccounts) { }
try {
const now = new Date(); if (accounts.length === 0) return;
if (backend && !account._id.startsWith('shared_')) await backend.shareAccount(account);
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0); const updatedAccounts = [...accounts];
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0); let scrapeChanges = false;
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8; for (const account of updatedAccounts) {
if (needsMetadata || needsCooldown || isManual) { try {
if (!isManual && needsCooldown) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000)); const now = new Date();
if (await scrapeAccountData(account)) scrapeChanges = true; const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
if ((now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName) {
const profile = await fetchProfileData(account.steamId, account.steamLoginSecure);
const bans = await scrapeBanStatus(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName; account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned; account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath) account.localAvatar = localPath;
} }
} catch (error) { } if (account.loginName) {
} const config = steamClient.extractAccountConfig(account.loginName);
if (scrapeChanges) { store.set('accounts', currentAccounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts); updateTrayMenu(); } if (config) { account.loginConfig = config; account.sessionUpdatedAt = new Date().toISOString(); }
console.log('[Sync] All phases complete.'); }
}; if (backend) await backend.shareAccount(account);
if (isManual) await runScrapes(); else runScrapes(); scrapeChanges = true;
}
if (account.autoCheckCooldown && account.steamLoginSecure) {
if (account.cooldownExpiresAt && new Date(account.cooldownExpiresAt) > now) continue;
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
if ((now.getTime() - lastScrape.getTime()) / 3600000 > 8) {
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 60000) + 5000));
try {
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
account.authError = false; account.lastScrapeTime = new Date().toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt);
} else if (account.cooldownExpiresAt) {
account.cooldownExpiresAt = undefined;
if (backend) await backend.pushCooldown(account.steamId, undefined);
}
scrapeChanges = true;
} catch (e: any) {
if (e.message.includes('cookie') || e.message.includes('Sign In')) { account.authError = true; scrapeChanges = true; }
}
}
}
} catch (error) { }
}
if (scrapeChanges) {
store.set('accounts', updatedAccounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', updatedAccounts);
updateTrayMenu();
}
}; };
const scheduleNextSync = () => { setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000); }; const scheduleNextSync = () => {
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
};
// --- Single Instance & Window --- // --- Discovery ---
const gotTheLock = app.requestSingleInstanceLock(); const addingAccounts = new Set<string>();
if (!gotTheLock) { const handleLocalAccountsFound = async (localAccounts: LocalSteamAccount[]) => {
app.quit(); const currentAccounts = store.get('accounts') as Account[];
} else { let hasChanges = false;
app.on('second-instance', () => { for (const local of localAccounts) {
if (mainWindow) { if (addingAccounts.has(local.steamId)) continue;
if (mainWindow.isMinimized()) mainWindow.restore(); const exists = currentAccounts.find(a => a.steamId === local.steamId);
mainWindow.show(); if (exists) {
mainWindow.focus(); if (!exists.loginName && local.accountName) { exists.loginName = local.accountName; hasChanges = true; }
} else {
addingAccounts.add(local.steamId);
try {
const profile = await fetchProfileData(local.steamId);
const bans = await scrapeBanStatus(profile.profileUrl);
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId, personaName: profile.personaName || local.accountName,
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
localAvatar: localPath, profileUrl: profile.profileUrl,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
});
hasChanges = true;
} catch (e) { }
addingAccounts.delete(local.steamId);
} }
}); }
if (hasChanges) {
app.whenReady().then(() => { store.set('accounts', currentAccounts);
protocol.handle('steam-resource', (request) => { if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', '')); updateTrayMenu();
if (process.platform !== 'win32' && !rawPath.startsWith('/')) rawPath = '/' + rawPath; }
const absolutePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath); };
if (!fs.existsSync(absolutePath)) return new Response('Not Found', { status: 404 });
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
});
createWindow();
createTray();
initBackend();
setTimeout(() => syncAccounts(false), 5000);
scheduleNextSync();
steamClient.startWatching(handleLocalAccountsFound);
});
}
// --- Main Window Creation ---
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true, width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
@@ -392,7 +344,7 @@ function createWindow() {
mainWindow.setMenu(null); mainWindow.setMenu(null);
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
if (!isQuitting) { if (!(app as any).isQuitting) {
event.preventDefault(); event.preventDefault();
mainWindow?.hide(); mainWindow?.hide();
} }
@@ -403,26 +355,26 @@ function createWindow() {
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html')); else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
} }
app.on('before-quit', () => { // --- App Lifecycle ---
console.log('[App] Preparing to quit...'); app.whenReady().then(() => {
(app as any).isQuitting = true; protocol.handle('steam-resource', (request) => {
if (tray) tray.destroy(); let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
if (process.platform !== 'win32' && !rawPath.startsWith('/')) rawPath = '/' + rawPath;
const absolutePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
if (!fs.existsSync(absolutePath)) return new Response('Not Found', { status: 404 });
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
});
createWindow();
createTray();
initBackend();
setTimeout(syncAccounts, 5000);
scheduleNextSync();
steamClient.startWatching(handleLocalAccountsFound);
}); });
app.on('window-all-closed', () => { if (process.platform !== 'darwin' && (app as any).isQuitting) app.quit(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin' && (app as any).isQuitting) app.quit(); });
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0 && gotTheLock) { /* Handled */ } else mainWindow?.show(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); else mainWindow?.show(); });
// Handle terminal termination (Ctrl+C / SIGTERM)
const handleSignal = (signal: string) => {
console.log(`[App] Received ${signal}`);
if (!(app as any).isQuitting && mainWindow) {
console.log(`[App] Hiding window instead of quitting due to ${signal}`);
mainWindow.hide();
}
else if ((app as any).isQuitting) { app.quit(); }
};
process.on('SIGINT', () => handleSignal('SIGINT'));
process.on('SIGTERM', () => handleSignal('SIGTERM'));
// --- IPC Handlers --- // --- IPC Handlers ---
ipcMain.handle('get-accounts', () => store.get('accounts')); ipcMain.handle('get-accounts', () => store.get('accounts'));
@@ -431,23 +383,30 @@ ipcMain.handle('update-server-config', (event, config: Partial<ServerConfig>) =>
const current = store.get('serverConfig'); const current = store.get('serverConfig');
const updated = { ...current, ...config }; const updated = { ...current, ...config };
store.set('serverConfig', updated); store.set('serverConfig', updated);
initBackend(); return updated; initBackend();
return updated;
}); });
ipcMain.handle('login-to-server', async () => { ipcMain.handle('login-to-server', async () => {
initBackend(); initBackend();
const config = store.get('serverConfig') as ServerConfig; const config = store.get('serverConfig') as ServerConfig;
if (!config.url) return false; if (!config.url) return false;
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const authWindow = new BrowserWindow({ width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server', webPreferences: { nodeIntegration: false, contextIsolation: true } }); const authWindow = new BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Ban Tracker Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`); authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false; let captured = false;
const saveServerAuth = (token: string) => { const saveServerAuth = (token: string) => {
if (captured) return; captured = true; if (captured) return; captured = true;
let serverSteamId = undefined; let isAdmin = false; let serverSteamId = undefined;
try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString()); serverSteamId = payload.steamId; isAdmin = !!payload.isAdmin; } catch (e) {} try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString()); serverSteamId = payload.steamId; } catch (e) {}
const current = store.get('serverConfig'); const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true }); store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
initBackend(); authWindow.close(); resolve(true); initBackend();
authWindow.close();
resolve(true);
}; };
const filter = { urls: [`${config.url}/*`] }; const filter = { urls: [`${config.url}/*`] };
authWindow.webContents.session.webRequest.onHeadersReceived(filter, (details, callback) => { authWindow.webContents.session.webRequest.onHeadersReceived(filter, (details, callback) => {
@@ -456,20 +415,15 @@ ipcMain.handle('login-to-server', async () => {
if (authToken) saveServerAuth(authToken); if (authToken) saveServerAuth(authToken);
callback({ cancel: false }); callback({ cancel: false });
}); });
authWindow.on('page-title-updated', (event, title) => { if (title.includes('AUTH_TOKEN:')) { const token = title.split('AUTH_TOKEN:')[1]; if (token) saveServerAuth(token); } }); authWindow.on('page-title-updated', (event, title) => {
if (title.includes('AUTH_TOKEN:')) { const token = title.split('AUTH_TOKEN:')[1]; if (token) saveServerAuth(token); }
});
authWindow.on('closed', () => resolve(false)); authWindow.on('closed', () => resolve(false));
}); });
}); });
ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId })); ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId }));
ipcMain.handle('sync-now', async () => { await syncAccounts(true); return true; }); ipcMain.handle('sync-now', async () => { await syncAccounts(); return true; });
ipcMain.handle('scrape-account', async (event, steamId: string) => {
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.steamId === steamId);
if (!account) return false;
const success = await scrapeAccountData(account);
if (success) { store.set('accounts', accounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts); updateTrayMenu(); }
return success;
});
ipcMain.handle('add-account', async (event, { identifier }) => { ipcMain.handle('add-account', async (event, { identifier }) => {
try { try {
initBackend(); initBackend();
@@ -486,29 +440,43 @@ ipcMain.handle('add-account', async (event, { identifier }) => {
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure, loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt, loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none', autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith lastBanCheck: new Date().toISOString()
}; };
store.set('accounts', [...accounts, newAccount]); updateTrayMenu(); return newAccount; store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
} }
} }
const profile = await fetchProfileData(identifier); const profile = await fetchProfileData(identifier);
const bans = await scrapeBanStatus(profile.profileUrl); const bans = await scrapeBanStatus(profile.profileUrl);
const localAvatar = await downloadAvatar(profile.steamId, profile.avatar); const localAvatar = await downloadAvatar(profile.steamId, profile.avatar);
const accounts = store.get('accounts') as Account[]; const accounts = store.get('accounts') as Account[];
const newAccount: Account = { _id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName, loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl, autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans, status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString() }; const newAccount: Account = {
store.set('accounts', [...accounts, newAccount]); updateTrayMenu(); return newAccount; _id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName,
loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl,
autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
} catch (error: any) { throw error; } } catch (error: any) { throw error; }
}); });
ipcMain.handle('update-account', (event, id: string, data: Partial<Account>) => { ipcMain.handle('update-account', (event, id: string, data: Partial<Account>) => {
const accounts = store.get('accounts') as Account[]; const accounts = store.get('accounts') as Account[];
const index = accounts.findIndex((a: Account) => a._id === id); const index = accounts.findIndex((a: Account) => a._id === id);
if (index !== -1) { accounts[index] = { ...accounts[index], ...data } as Account; store.set('accounts', accounts); updateTrayMenu(); return accounts[index]; } if (index !== -1) { accounts[index] = { ...accounts[index], ...data } as Account; store.set('accounts', accounts); updateTrayMenu(); return accounts[index]; }
return null; return null;
}); });
ipcMain.handle('delete-account', (event, id: string) => { ipcMain.handle('delete-account', (event, id: string) => {
const accounts = store.get('accounts') as Account[]; const accounts = store.get('accounts') as Account[];
store.set('accounts', accounts.filter((a: Account) => a._id !== id)); updateTrayMenu(); return true; store.set('accounts', accounts.filter((a: Account) => a._id !== id));
updateTrayMenu();
return true;
}); });
ipcMain.handle('share-account-with-user', async (event, steamId: string, targetSteamId: string) => { ipcMain.handle('share-account-with-user', async (event, steamId: string, targetSteamId: string) => {
initBackend(); initBackend();
if (backend) { if (backend) {
@@ -519,65 +487,84 @@ ipcMain.handle('share-account-with-user', async (event, steamId: string, targetS
} }
throw new Error('Backend not configured'); throw new Error('Backend not configured');
}); });
ipcMain.handle('revoke-account-access', async (event, steamId: string, targetSteamId: string) => { initBackend(); if (backend) return await backend.revokeAccess(steamId, targetSteamId); throw new Error('Backend not configured'); });
ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => { initBackend(); if (backend) return await backend.revokeAllAccess(steamId); throw new Error('Backend not configured'); }); ipcMain.handle('revoke-account-access', async (event, steamId: string, targetSteamId: string) => {
initBackend();
if (backend) return await backend.revokeAccess(steamId, targetSteamId);
throw new Error('Backend not configured');
});
ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => {
initBackend();
if (backend) return await backend.revokeAllAccess(steamId);
throw new Error('Backend not configured');
});
ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; }); ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; }); ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; }); ipcMain.handle('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName));
ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBackend(); if (backend) await backend.deleteUser(userId); return true; });
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
ipcMain.handle('update-app-icon', (event, themeName: string) => { setAppIcon(themeName); return true; });
ipcMain.handle('switch-account', async (event, loginName: string) => {
if (!loginName) return false;
try {
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steamClient.extractAccountConfig(loginName);
if (freshConfig) { account.loginConfig = freshConfig; account.sessionUpdatedAt = new Date().toISOString(); if (backend) await backend.shareAccount(account); store.set('accounts', accounts); }
}
await killSteam();
if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
const commands = [`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`];
await new Promise<void>((res, rej) => exec(commands.join(' && '), (e) => e ? rej(e) : res()));
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
} else if (process.platform === 'linux') { await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId); }
startSteam(); return true;
} catch (e) { return false; }
});
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url)); ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));
ipcMain.handle('open-steam-app-login', async () => { ipcMain.handle('open-steam-app-login', async () => {
console.log('[SteamClient] Preparing for fresh login...');
await killSteam(); await killSteam();
if (process.platform === 'win32') { if (process.platform === 'win32') {
// Clear auto-login registry
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f'; const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
await new Promise<void>((res) => exec(clearReg, () => res())); await new Promise<void>((res) => exec(clearReg, () => res()));
} else if (process.platform === 'linux') { await steamClient.setAutoLoginUser("", undefined, ""); } } else if (process.platform === 'linux') {
// On Linux we can use the steamClient helper to set an empty user
await steamClient.setAutoLoginUser("", undefined, "");
}
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login'; const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
exec(command); return true; exec(command);
return true;
}); });
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => { ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
// Use a unique partition per account to prevent session bleeding
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new'; const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
const loginSession = session.fromPartition(partitionId); const loginSession = session.fromPartition(partitionId);
if (!expectedSteamId) await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
// If adding a brand new account, explicitly clear previous trash
if (!expectedSteamId) {
console.log('[Auth] Clearing session for new account login...');
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
}
// If we have an existing cookie string for this account, pre-inject it
if (expectedSteamId) { if (expectedSteamId) {
const accounts = store.get('accounts') as Account[]; const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.steamId === expectedSteamId); const account = accounts.find(a => a.steamId === expectedSteamId);
if (account?.steamLoginSecure) { if (account?.steamLoginSecure) {
console.log(`[Auth] Pre-injecting existing cookies for ${account.personaName}...`);
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim()); const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
for (const pair of cookiePairs) { for (const pair of cookiePairs) {
const [name, value] = pair.split('='); const [name, value] = pair.split('=');
if (name && value) { if (name && value) {
try { await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') }); } catch (e) {} try {
await loginSession.cookies.set({
url: 'https://steamcommunity.com',
domain: 'steamcommunity.com',
name: name,
value: value,
path: '/',
secure: true,
httpOnly: name.includes('Secure')
});
} catch (e) {}
} }
} }
} }
} }
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const loginWindow = new BrowserWindow({ width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam', webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId } }); const loginWindow = new BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
});
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730'); loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
const checkCookie = setInterval(async () => { const checkCookie = setInterval(async () => {
try { try {
@@ -593,11 +580,24 @@ ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
const accountIndex = accounts.findIndex(a => a.steamId === steamId); const accountIndex = accounts.findIndex(a => a.steamId === steamId);
if (accountIndex !== -1) { if (accountIndex !== -1) {
const account = accounts[accountIndex]!; const account = accounts[accountIndex]!;
account.steamLoginSecure = cookieString; account.autoCheckCooldown = true; account.authError = false; account.sessionUpdatedAt = new Date().toISOString(); account.steamLoginSecure = cookieString; account.autoCheckCooldown = true; account.authError = false;
if (account.loginName) { const config = steamClient.extractAccountConfig(account.loginName); if (config) account.loginConfig = config; } account.sessionUpdatedAt = new Date().toISOString();
try { const result = await scrapeCooldown(account.steamId, cookieString); account.lastScrapeTime = new Date().toISOString(); account.cooldownExpiresAt = result.isActive && result.expiresAt ? result.expiresAt.toISOString() : undefined; } catch (e) { } if (account.loginName) {
initBackend(); if (backend) await backend.shareAccount(account); const config = steamClient.extractAccountConfig(account.loginName);
store.set('accounts', accounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts); updateTrayMenu(); loginWindow.close(); resolve(true); if (config) account.loginConfig = config;
}
try {
const result = await scrapeCooldown(account.steamId, cookieString);
account.lastScrapeTime = new Date().toISOString();
account.cooldownExpiresAt = result.isActive && result.expiresAt ? result.expiresAt.toISOString() : undefined;
} catch (e) { }
initBackend();
if (backend) await backend.shareAccount(account);
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
loginWindow.close();
resolve(true);
} }
} }
} }

View File

@@ -10,7 +10,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
revokeAccountAccess: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId), revokeAccountAccess: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
revokeAllAccountAccess: (steamId: string) => ipcRenderer.invoke('revoke-all-account-access', steamId), revokeAllAccountAccess: (steamId: string) => ipcRenderer.invoke('revoke-all-account-access', steamId),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url), openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
updateAppIcon: (theme: string) => ipcRenderer.invoke('update-app-icon', theme),
openSteamAppLogin: () => ipcRenderer.invoke('open-steam-app-login'), openSteamAppLogin: () => ipcRenderer.invoke('open-steam-app-login'),
openSteamLogin: (steamId: string) => ipcRenderer.invoke('open-steam-login', steamId), openSteamLogin: (steamId: string) => ipcRenderer.invoke('open-steam-login', steamId),
@@ -20,17 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
loginToServer: () => ipcRenderer.invoke('login-to-server'), loginToServer: () => ipcRenderer.invoke('login-to-server'),
getServerUserInfo: () => ipcRenderer.invoke('get-server-user-info'), getServerUserInfo: () => ipcRenderer.invoke('get-server-user-info'),
syncNow: () => ipcRenderer.invoke('sync-now'), syncNow: () => ipcRenderer.invoke('sync-now'),
scrapeAccount: (steamId: string) => ipcRenderer.invoke('scrape-account', steamId),
getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'), getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => ipcRenderer.invoke('get-server-users'), getServerUsers: () => ipcRenderer.invoke('get-server-users'),
// Admin API
adminGetStats: () => ipcRenderer.invoke('admin-get-stats'),
adminGetUsers: () => ipcRenderer.invoke('admin-get-users'),
adminDeleteUser: (userId: string) => ipcRenderer.invoke('admin-delete-user', userId),
adminGetAccounts: () => ipcRenderer.invoke('admin-get-accounts'),
adminRemoveAccount: (steamId: string) => ipcRenderer.invoke('admin-remove-account', steamId),
onAccountsUpdated: (callback: (accounts: any[]) => void) => { onAccountsUpdated: (callback: (accounts: any[]) => void) => {
const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts); const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts);
ipcRenderer.on('accounts-updated', subscription); ipcRenderer.on('accounts-updated', subscription);

View File

@@ -62,22 +62,18 @@ export class BackendService {
loginName: account.loginName, loginName: account.loginName,
steamLoginSecure: account.steamLoginSecure, steamLoginSecure: account.steamLoginSecure,
loginConfig: account.loginConfig, loginConfig: account.loginConfig,
sessionUpdatedAt: account.sessionUpdatedAt, sessionUpdatedAt: account.sessionUpdatedAt
lastMetadataCheck: account.lastBanCheck,
lastScrapeTime: account.lastScrapeTime,
cooldownExpiresAt: account.cooldownExpiresAt
}, { headers: this.headers }); }, { headers: this.headers });
} catch (e) { } catch (e) {
console.error('[Backend] Failed to share account'); console.error('[Backend] Failed to share account');
} }
} }
public async pushCooldown(steamId: string, cooldownExpiresAt?: string, lastScrapeTime?: string) { public async pushCooldown(steamId: string, cooldownExpiresAt?: string) {
if (!this.token) return; if (!this.token) return;
try { try {
await axios.patch(`${this.url}/api/sync/${steamId}/cooldown`, { await axios.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt, cooldownExpiresAt
lastScrapeTime
}, { headers: this.headers }); }, { headers: this.headers });
} catch (e) { } catch (e) {
console.error(`[Backend] Failed to push cooldown for ${steamId}`); console.error(`[Backend] Failed to push cooldown for ${steamId}`);
@@ -123,48 +119,4 @@ export class BackendService {
throw new Error(e.response?.data?.message || 'Failed to revoke all access'); throw new Error(e.response?.data?.message || 'Failed to revoke all access');
} }
} }
// --- Admin API ---
public async getAdminStats() {
if (!this.token) return null;
try {
const response = await axios.get(`${this.url}/api/admin/stats`, { headers: this.headers });
return response.data;
} catch (e) { return null; }
}
public async getAdminUsers() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/users`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async deleteUser(userId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to delete user');
}
}
public async getAdminAccounts() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async forceRemoveAccount(steamId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to remove account');
}
}
} }

View File

@@ -6,14 +6,6 @@ export interface CooldownData {
expiresAt?: Date; expiresAt?: Date;
} }
// Custom error to identify session death
export class SteamAuthError extends Error {
constructor(message: string) {
super(message);
this.name = "SteamAuthError";
}
}
export const scrapeCooldown = async (steamId: string, steamLoginSecure: string): Promise<CooldownData> => { export const scrapeCooldown = async (steamId: string, steamLoginSecure: string): Promise<CooldownData> => {
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`; const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
@@ -23,44 +15,43 @@ export const scrapeCooldown = async (steamId: string, steamLoginSecure: string):
'Cookie': steamLoginSecure, 'Cookie': steamLoginSecure,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}, },
timeout: 10000, timeout: 10000
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
}); });
// If Steam redirects us to the login page, the cookie is dead
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
}
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
if (!response.data.includes('Personal Game Data')) { if (response.data.includes('Sign In') || !response.data.includes('Personal Game Data')) {
throw new SteamAuthError('Session invalid: Personal Game Data not accessible'); throw new Error('Invalid or expired steamLoginSecure cookie');
} }
// 1. Locate the specific table containing cooldown info
let expirationDate: Date | undefined = undefined; let expirationDate: Date | undefined = undefined;
$('table').each((_, table) => { $('table').each((_, table) => {
const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get(); const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get();
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration')); const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration'));
if (expirationIndex !== -1) { if (expirationIndex !== -1) {
const rows = $(table).find('tr').not(':has(th)'); const firstRow = $(table).find('tr').not(':has(th)').first();
rows.each((_, row) => { const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
if (dateText && dateText !== '') { if (dateText && dateText !== '') {
const cleanDateText = dateText.replace(' GMT', ' UTC'); const cleanDateText = dateText.replace(' GMT', ' UTC');
const parsed = new Date(cleanDateText); const parsed = new Date(cleanDateText);
if (!isNaN(parsed.getTime())) {
if (!expirationDate || parsed > (expirationDate as Date)) expirationDate = parsed; if (!isNaN(parsed.getTime())) {
} expirationDate = parsed;
} }
}); }
} }
}); });
if (expirationDate && (expirationDate as Date).getTime() > Date.now()) { if (expirationDate && (expirationDate as Date).getTime() > Date.now()) {
return { isActive: true, expiresAt: expirationDate }; console.log(`[Scraper] Found active cooldown until: ${(expirationDate as Date).toISOString()}`);
return {
isActive: true,
expiresAt: expirationDate
};
} }
const content = $('#personal_game_data_content').text(); const content = $('#personal_game_data_content').text();
@@ -70,8 +61,7 @@ export const scrapeCooldown = async (steamId: string, steamLoginSecure: string):
return { isActive: false }; return { isActive: false };
} catch (error: any) { } catch (error: any) {
if (error instanceof SteamAuthError) throw error; console.error(`[Scraper] Error for ${steamId}:`, error.message);
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message); throw error;
throw error; // Generic errors don't trigger re-auth
} }
}; };

View File

@@ -26,16 +26,14 @@ class SteamClientService {
if (platform === 'win32') { if (platform === 'win32') {
const possiblePaths = [ const possiblePaths = [
'C:\\Program Files (x86)\\Steam', 'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam', 'C:\\Program Files\\Steam'
path.join(process.env.APPDATA || '', 'Steam'),
]; ];
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null; this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
} else if (platform === 'linux') { } else if (platform === 'linux') {
const possiblePaths = [ const possiblePaths = [
path.join(home, '.steam/steam'), path.join(home, '.steam/steam'),
path.join(home, '.local/share/Steam'), path.join(home, '.local/share/Steam'),
path.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak path.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam')
path.join(home, 'snap/steam/common/.steam/steam'), // Snap
]; ];
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null; this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
} }
@@ -55,27 +53,13 @@ class SteamClientService {
return path.join(this.steamPath, 'config', 'config.vdf'); return path.join(this.steamPath, 'config', 'config.vdf');
} }
private safeWriteVdf(filePath: string, data: any) {
const tempPath = `${filePath}.tmp_${Date.now()}`;
const dir = path.dirname(filePath);
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const vdfContent = stringify(data);
fs.writeFileSync(tempPath, vdfContent, 'utf-8');
fs.renameSync(tempPath, filePath);
} catch (e: any) {
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
throw e;
}
}
public startWatching(callback: (accounts: LocalSteamAccount[]) => void) { public startWatching(callback: (accounts: LocalSteamAccount[]) => void) {
this.onAccountsChanged = callback; this.onAccountsChanged = callback;
const loginUsersPath = this.getLoginUsersPath(); const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath && fs.existsSync(loginUsersPath)) { if (loginUsersPath && fs.existsSync(loginUsersPath)) {
this.readLocalAccounts(); this.readLocalAccounts();
chokidar.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => { chokidar.watch(loginUsersPath, { persistent: true }).on('change', () => {
this.readLocalAccounts(); this.readLocalAccounts();
}); });
} }
@@ -84,72 +68,79 @@ class SteamClientService {
private readLocalAccounts() { private readLocalAccounts() {
const filePath = this.getLoginUsersPath(); const filePath = this.getLoginUsersPath();
if (!filePath || !fs.existsSync(filePath)) return; if (!filePath || !fs.existsSync(filePath)) return;
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
if (!content.trim()) return;
const data = parse(content) as any; const data = parse(content) as any;
if (!data || !data.users) return; if (!data || !data.users) return;
const accounts: LocalSteamAccount[] = []; const accounts: LocalSteamAccount[] = [];
for (const [steamId64, userData] of Object.entries(data.users)) { for (const [steamId64, userData] of Object.entries(data.users)) {
const user = userData as any; const user = userData as any;
if (!user || !user.AccountName) continue;
accounts.push({ accounts.push({
steamId: steamId64, accountName: user.AccountName, steamId: steamId64,
personaName: user.PersonaName || user.AccountName, accountName: user.AccountName,
personaName: user.PersonaName,
timestamp: parseInt(user.Timestamp) || 0 timestamp: parseInt(user.Timestamp) || 0
}); });
} }
if (this.onAccountsChanged) this.onAccountsChanged(accounts); if (this.onAccountsChanged) this.onAccountsChanged(accounts);
} catch (error) { } } catch (error) {
console.error('[SteamClient] Error parsing loginusers.vdf:', error);
}
} }
public extractAccountConfig(accountName: string): any | null { public extractAccountConfig(accountName: string): any | null {
const configPath = this.getConfigVdfPath(); const configPath = this.getConfigVdfPath();
if (!configPath || !fs.existsSync(configPath)) return null; if (!configPath || !fs.existsSync(configPath)) return null;
try { try {
const content = fs.readFileSync(configPath, 'utf-8'); const content = fs.readFileSync(configPath, 'utf-8');
const data = parse(content) as any; const data = parse(content) as any;
const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts; const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts;
return (accounts && accounts[accountName]) ? accounts[accountName] : null; if (accounts && accounts[accountName]) {
} catch (e) { return null; } return accounts[accountName];
}
} catch (e) {
console.error('[SteamClient] Failed to extract config.vdf data');
}
return null;
} }
public injectAccountConfig(accountName: string, accountData: any) { public injectAccountConfig(accountName: string, accountData: any) {
const configPath = this.getConfigVdfPath(); const configPath = this.getConfigVdfPath();
if (!configPath) return; if (!configPath) return;
// Create directory if it doesn't exist
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
let data: any = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } }; let data: any = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } };
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
try { try {
const content = fs.readFileSync(configPath, 'utf-8'); const content = fs.readFileSync(configPath, 'utf-8');
const parsed = parse(content) as any; data = parse(content) as any;
if (parsed && typeof parsed === 'object') data = parsed;
} catch (e) { } } catch (e) { }
} }
const ensurePath = (obj: any, keys: string[]) => { // Ensure structure exists
let curr = obj; if (!data.InstallConfigStore) data.InstallConfigStore = {};
for (const key of keys) { if (!data.InstallConfigStore.Software) data.InstallConfigStore.Software = {};
if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {}; if (!data.InstallConfigStore.Software.Valve) data.InstallConfigStore.Software.Valve = {};
curr = curr[key]; if (!data.InstallConfigStore.Software.Valve.Steam) data.InstallConfigStore.Software.Valve.Steam = {};
} if (!data.InstallConfigStore.Software.Valve.Steam.Accounts) data.InstallConfigStore.Software.Valve.Steam.Accounts = {};
return curr;
};
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']); data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
// FAILPROOF: Force crucial flags that Steam uses to decide session validity
steamAccounts[accountName] = {
...accountData,
RememberPassword: "1",
AllowAutoLogin: "1",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
try { try {
this.safeWriteVdf(configPath, data); fs.writeFileSync(configPath, stringify(data));
console.log(`[SteamClient] Safely injected session for ${accountName}`); console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`);
} catch (e) { } } catch (e) {
console.error('[SteamClient] Failed to write config.vdf');
}
} }
public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise<boolean> { public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise<boolean> {
@@ -157,12 +148,14 @@ class SteamClientService {
const loginUsersPath = this.getLoginUsersPath(); const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath) { if (loginUsersPath) {
const configDir = path.dirname(loginUsersPath);
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
let data: any = { users: {} }; let data: any = { users: {} };
if (fs.existsSync(loginUsersPath)) { if (fs.existsSync(loginUsersPath)) {
try { try {
const content = fs.readFileSync(loginUsersPath, 'utf-8'); const content = fs.readFileSync(loginUsersPath, 'utf-8');
const parsed = parse(content) as any; data = parse(content) as any;
if (parsed && parsed.users) data = parsed;
} catch (e) { } } catch (e) { }
} }
@@ -171,7 +164,7 @@ class SteamClientService {
let found = false; let found = false;
for (const [id, user] of Object.entries(data.users)) { for (const [id, user] of Object.entries(data.users)) {
const u = user as any; const u = user as any;
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) { if (u.AccountName.toLowerCase() === accountName.toLowerCase()) {
u.mostrecent = "1"; u.mostrecent = "1";
u.RememberPassword = "1"; u.RememberPassword = "1";
u.AllowAutoLogin = "1"; u.AllowAutoLogin = "1";
@@ -184,8 +177,8 @@ class SteamClientService {
} }
} }
if (!found && steamId && accountName) { if (!found && steamId) {
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`); console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
data.users[steamId] = { data.users[steamId] = {
AccountName: accountName, AccountName: accountName,
PersonaName: accountName, PersonaName: accountName,
@@ -200,16 +193,16 @@ class SteamClientService {
} }
try { try {
this.safeWriteVdf(loginUsersPath, data); fs.writeFileSync(loginUsersPath, stringify(data));
} catch (e) { } } catch (e) {
console.error('[SteamClient] Failed to write loginusers.vdf');
}
} }
// Injection of the actual authentication blob if (accountConfig) {
if (accountConfig && accountName) {
this.injectAccountConfig(accountName, accountConfig); this.injectAccountConfig(accountName, accountConfig);
} }
// --- Linux Registry / Registry.vdf Hardening ---
if (platform === 'linux') { if (platform === 'linux') {
const regLocations = [ const regLocations = [
path.join(os.homedir(), '.steam', 'registry.vdf'), path.join(os.homedir(), '.steam', 'registry.vdf'),
@@ -217,28 +210,37 @@ class SteamClientService {
]; ];
for (const regPath of regLocations) { for (const regPath of regLocations) {
if (!fs.existsSync(path.dirname(regPath))) continue; let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
if (fs.existsSync(regPath)) { if (fs.existsSync(regPath)) {
try { try {
const content = fs.readFileSync(regPath, 'utf-8'); const content = fs.readFileSync(regPath, 'utf-8');
const parsed = parse(content) as any; regData = parse(content) as any;
if (parsed && typeof parsed === 'object') regData = parsed;
} catch (e) { } } catch (e) { }
} else {
const regDir = path.dirname(regPath);
if (!fs.existsSync(regDir)) fs.mkdirSync(regDir, { recursive: true });
} }
const ensurePath = (obj: any, keys: string[]) => { const setPath = (obj: any, keys: string[], val: string) => {
let curr = obj; let curr = obj;
for (const key of keys) { if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {}; curr = curr[key]; } for (let i = 0; i < keys.length - 1; i++) {
return curr; if (!curr[keys[i]!]) curr[keys[i]!] = {};
curr = curr[keys[i]!];
}
curr[keys[keys.length - 1]!] = val;
}; };
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']); const steamReg = ['Registry', 'HKCU', 'Software', 'Valve', 'Steam'];
steamKey.AutoLoginUser = accountName; setPath(regData, [...steamReg, 'AutoLoginUser'], accountName);
steamKey.RememberPassword = "1"; setPath(regData, [...steamReg, 'RememberPassword'], "1");
steamKey.AlreadyLoggedIn = "1"; setPath(regData, [...steamReg, 'AlreadyLoggedIn'], "1");
steamKey.WantsOfflineMode = "0"; setPath(regData, [...steamReg, 'WantsOfflineMode'], "0");
try { this.safeWriteVdf(regPath, regData); } catch (e) { }
try {
fs.writeFileSync(regPath, stringify(regData));
console.log(`[SteamClient] Registry updated: ${regPath}`);
} catch (e) { }
} }
} }

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ultimate Ban Tracker</title> <title>frontend</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,12 +1,12 @@
{ {
"name": "ultimate-ban-tracker-desktop", "name": "ultimate-ban-tracker-desktop",
"version": "1.3.3", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ultimate-ban-tracker-desktop", "name": "ultimate-ban-tracker-desktop",
"version": "1.3.3", "version": "1.2.0",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "ultimate-ban-tracker-desktop", "name": "ultimate-ban-tracker-desktop",
"description": "Professional Steam Account Manager & Ban Tracker", "description": "Professional Steam Account Manager & Ban Tracker",
"version": "1.3.3", "version": "1.2.0",
"author": "Nils Pukropp <nils@narl.io>", "author": "Nils Pukropp <nils@narl.io>",
"homepage": "https://narl.io", "homepage": "https://narl.io",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
@@ -13,12 +13,11 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "concurrently -k \"cross-env BROWSER=none npm run dev\" \"npx tsc -p electron/tsconfig.json -w\" \"wait-on http://localhost:5173 && wait-on dist-electron/main.js && electron .\"", "electron:dev": "concurrently -k \"cross-env BROWSER=none npm run dev\" \"npx tsc -p electron/tsconfig.json -w\" \"wait-on http://localhost:5173 && wait-on dist-electron/main.js && electron .\"",
"electron:build": "vite build && npx tsc -p electron/tsconfig.json && electron-builder --publish never" "electron:build": "vite build && npx tsc -p electron/tsconfig.json && npm prune --production && npm install && electron-builder --publish never"
}, },
"build": { "build": {
"appId": "io.narl.ultimatebantracker", "appId": "io.narl.ultimatebantracker",
"productName": "Ultimate Ban Tracker", "productName": "Ultimate Ban Tracker",
"executableName": "ultimate-ban-tracker",
"copyright": "Copyright © 2026 Nils Pukropp", "copyright": "Copyright © 2026 Nils Pukropp",
"directories": { "directories": {
"output": "release" "output": "release"
@@ -29,8 +28,7 @@
}, },
"files": [ "files": [
"dist/**/*", "dist/**/*",
"dist-electron/**/*", "dist-electron/**/*"
"assets-build/**/*"
], ],
"linux": { "linux": {
"target": [ "target": [

View File

@@ -27,7 +27,6 @@ export interface ServerConfig {
token?: string; token?: string;
serverSteamId?: string; serverSteamId?: string;
enabled: boolean; enabled: boolean;
isAdmin?: boolean;
} }
interface AccountsContextType { interface AccountsContextType {
@@ -49,17 +48,9 @@ interface AccountsContextType {
updateServerConfig: (config: Partial<ServerConfig>) => Promise<void>; updateServerConfig: (config: Partial<ServerConfig>) => Promise<void>;
loginToServer: () => Promise<void>; loginToServer: () => Promise<void>;
syncNow: () => Promise<void>; syncNow: () => Promise<void>;
scrapeAccount: (steamId: string) => Promise<boolean>;
getCommunityAccounts: () => Promise<any[]>; getCommunityAccounts: () => Promise<any[]>;
getServerUsers: () => Promise<any[]>; getServerUsers: () => Promise<any[]>;
refreshAccounts: (showLoading?: boolean) => Promise<void>; refreshAccounts: (showLoading?: boolean) => Promise<void>;
// Admin Methods
adminGetStats: () => Promise<any>;
adminGetUsers: () => Promise<any[]>;
adminDeleteUser: (userId: string) => Promise<void>;
adminGetAccounts: () => Promise<any[]>;
adminRemoveAccount: (steamId: string) => Promise<void>;
} }
const AccountsContext = createContext<AccountsContextType | undefined>(undefined); const AccountsContext = createContext<AccountsContextType | undefined>(undefined);
@@ -115,12 +106,6 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
} }
}; };
const scrapeAccount = async (steamId: string) => {
const success = await (window as any).electronAPI.scrapeAccount(steamId);
if (success) await syncNow();
return success;
};
const addAccount = async (data: { identifier: string }) => { const addAccount = async (data: { identifier: string }) => {
await (window as any).electronAPI.addAccount(data); await (window as any).electronAPI.addAccount(data);
await refreshAccounts(); await refreshAccounts();
@@ -189,19 +174,11 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return await (window as any).electronAPI.getServerUsers(); return await (window as any).electronAPI.getServerUsers();
}; };
// --- Admin Methods ---
const adminGetStats = async () => (window as any).electronAPI.adminGetStats();
const adminGetUsers = async () => (window as any).electronAPI.adminGetUsers();
const adminDeleteUser = async (userId: string) => (window as any).electronAPI.adminDeleteUser(userId);
const adminGetAccounts = async () => (window as any).electronAPI.adminGetAccounts();
const adminRemoveAccount = async (steamId: string) => (window as any).electronAPI.adminRemoveAccount(steamId);
return ( return (
<AccountsContext.Provider value={{ <AccountsContext.Provider value={{
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount, accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer, switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer,
getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts, getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts
scrapeAccount, adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
}}> }}>
{children} {children}
</AccountsContext.Provider> </AccountsContext.Provider>

View File

@@ -6,7 +6,7 @@ import {
DialogActions, CircularProgress, Paper, Chip, DialogActions, CircularProgress, Paper, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction, Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction,
Select, MenuItem, FormControl, InputLabel, Tabs, Tab Select, MenuItem, FormControl, InputLabel
} from '@mui/material'; } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
@@ -25,116 +25,11 @@ import GppBadIcon from '@mui/icons-material/GppBad';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import StorageIcon from '@mui/icons-material/Storage';
import GroupIcon from '@mui/icons-material/Group';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import { useAccounts, type Account } from '../hooks/useAccounts'; import { useAccounts, type Account } from '../hooks/useAccounts';
import { useAppTheme } from '../theme/ThemeContext'; import { useAppTheme } from '../theme/ThemeContext';
import type { ThemeType } from '../theme/SteamTheme'; import type { ThemeType } from '../theme/SteamTheme';
import NebulaBanner from '../components/NebulaBanner'; import NebulaBanner from '../components/NebulaBanner';
const AdminPanel: React.FC<{ open: boolean, onClose: () => void }> = ({ open, onClose }) => {
const { adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount } = useAccounts();
const [tab, setTab] = useState(0);
const [stats, setStats] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
if (tab === 0) setStats(await adminGetStats());
if (tab === 1) setUsers(await adminGetUsers());
if (tab === 2) setAccounts(await adminGetAccounts());
} catch (e) {}
setLoading(false);
};
useEffect(() => { if (open) loadData(); }, [open, tab]);
const handleDeleteUser = async (id: string) => {
if (window.confirm("Wipe this user and all their accounts?")) {
await adminDeleteUser(id);
loadData();
}
};
const handleForceRemove = async (steamId: string) => {
if (window.confirm("Force remove this account from server?")) {
await adminRemoveAccount(steamId);
loadData();
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ bgcolor: 'background.paper', color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
<AdminPanelSettingsIcon color="primary" /> Server Administration
</DialogTitle>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Tab icon={<StorageIcon />} label="Overview" />
<Tab icon={<GroupIcon />} label="Users" />
<Tab icon={<AccountTreeIcon />} label="Global Accounts" />
</Tabs>
<DialogContent sx={{ bgcolor: 'background.paper', minHeight: 400, pt: 2 }}>
{loading ? <Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}><CircularProgress /></Box> : (
<>
{tab === 0 && stats && (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 2 }}>
{[
{ label: 'Total Users', value: stats.users },
{ label: 'Total Accounts', value: stats.accounts },
{ label: 'Active Cooldowns', value: stats.activeCooldowns }
].map((s) => (
<Paper key={s.label} sx={{ p: 3, textAlign: 'center', bgcolor: 'rgba(0,0,0,0.1)' }}>
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>{s.value}</Typography>
<Typography variant="caption" color="textSecondary">{s.label}</Typography>
</Paper>
))}
</Box>
)}
{tab === 1 && (
<List>
{users.map(u => (
<ListItem key={u._id} divider sx={{ borderColor: 'divider' }}>
<Avatar src={u.avatar} sx={{ mr: 2 }} />
<ListItemText primary={u.personaName} secondary={u.steamId} primaryTypographyProps={{ color: 'text.primary' }} />
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleDeleteUser(u._id)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{tab === 2 && (
<List>
{accounts.map(a => (
<ListItem key={a.steamId} divider sx={{ borderColor: 'divider' }}>
<Avatar src={a.avatar} variant="square" sx={{ mr: 2 }} />
<ListItemText
primary={a.personaName}
secondary={`Owned by: ${a.addedBy?.personaName || 'Unknown'} (${a.steamId})`}
primaryTypographyProps={{ color: 'text.primary' }}
/>
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleForceRemove(a.steamId)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ bgcolor: 'background.paper', p: 2 }}>
<Button onClick={onClose} variant="contained" color="inherit">Close Panel</Button>
</DialogActions>
</Dialog>
);
};
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { currentTheme, setTheme } = useAppTheme(); const { currentTheme, setTheme } = useAppTheme();
const { const {
@@ -144,7 +39,6 @@ const Dashboard: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAdminPanelOpen, setIsAdminPanelOpen] = useState(false);
const [serverUrl, setServerUrl] = useState(''); const [serverUrl, setServerUrl] = useState('');
useEffect(() => { useEffect(() => {
@@ -176,15 +70,6 @@ const Dashboard: React.FC = () => {
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}>
{/* Admin Button - Only visible if isAdmin is true */}
{serverConfig?.isAdmin && (
<Tooltip title="Open Admin Panel">
<IconButton color="primary" onClick={() => setIsAdminPanelOpen(true)}>
<AdminPanelSettingsIcon />
</IconButton>
</Tooltip>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
{isSyncing ? ( {isSyncing ? (
<CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} /> <CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} />
@@ -361,9 +246,6 @@ const Dashboard: React.FC = () => {
<Button onClick={() => setIsSettingsOpen(false)} color="inherit" variant="contained">Done</Button> <Button onClick={() => setIsSettingsOpen(false)} color="inherit" variant="contained">Done</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Admin Panel */}
<AdminPanel open={isAdminPanelOpen} onClose={() => setIsAdminPanelOpen(false)} />
</Box> </Box>
); );
}; };
@@ -376,12 +258,11 @@ const AccountRow: React.FC<{
onSwitch: (login: string) => void, onSwitch: (login: string) => void,
onAuth: () => void onAuth: () => void
}> = ({ account, onDelete, onSwitch, onAuth }) => { }> = ({ account, onDelete, onSwitch, onAuth }) => {
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig, scrapeAccount } = useAccounts(); const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig } = useAccounts();
const [timeLeft, setTimeLeft] = useState<string | null>(null); const [timeLeft, setTimeLeft] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false); const [isShareOpen, setIsShareOpen] = useState(false);
const [targetUserId, setTargetUserId] = useState(''); const [targetUserId, setTargetUserId] = useState('');
const [isSharing, setIsSharing] = useState(false); const [isSharing, setIsSharing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [serverUsers, setServerUsers] = useState<any[]>([]); const [serverUsers, setServerUsers] = useState<any[]>([]);
const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null; const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
@@ -405,12 +286,6 @@ const AccountRow: React.FC<{
const [imgSrc, setImgSrc] = useState(avatarSrc); const [imgSrc, setImgSrc] = useState(avatarSrc);
useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]); useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]);
const handleRefresh = async () => {
setIsRefreshing(true);
await scrapeAccount(account.steamId);
setIsRefreshing(false);
};
const handleOpenShare = async () => { const handleOpenShare = async () => {
setIsShareOpen(true); setIsShareOpen(true);
try { try {
@@ -419,7 +294,8 @@ const AccountRow: React.FC<{
(window as any).electronAPI.getServerUserInfo() (window as any).electronAPI.getServerUserInfo()
]); ]);
const filtered = (Array.isArray(users) ? users : []).filter(u => const filtered = (Array.isArray(users) ? users : []).filter(u =>
u.steamId !== selfInfo.steamId && u.steamId !== account.steamId u.steamId !== selfInfo.steamId &&
u.steamId !== account.steamId
); );
setServerUsers(filtered); setServerUsers(filtered);
} catch (e) {} } catch (e) {}
@@ -529,12 +405,7 @@ const AccountRow: React.FC<{
{account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)} {account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)}
</IconButton> </IconButton>
{account.steamLoginSecure && !account.authError && ( {account.steamLoginSecure && !account.authError && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem' }}>TRACKING</Typography>
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem' }}>TRACKING</Typography>
<IconButton size="small" onClick={handleRefresh} disabled={isRefreshing} sx={{ p: 0.2, color: 'text.secondary', '&:hover': { color: 'primary.main' } }}>
{isRefreshing ? <CircularProgress size={10} color="inherit" /> : <SyncIcon sx={{ fontSize: 12 }} />}
</IconButton>
</Box>
)} )}
</Box> </Box>
</Tooltip> </Tooltip>

View File

@@ -31,27 +31,8 @@ export const AppThemeProvider: React.FC<{ children: React.ReactNode }> = ({ chil
if (api?.updateServerConfig) { if (api?.updateServerConfig) {
await api.updateServerConfig({ theme }); await api.updateServerConfig({ theme });
} }
if (api?.updateAppIcon) {
try {
await api.updateAppIcon(theme);
} catch (e) { }
}
}; };
useEffect(() => {
const updateIcon = async () => {
const api = (window as any).electronAPI;
if (api?.updateAppIcon && currentTheme) {
try {
await api.updateAppIcon(currentTheme);
} catch (e) {
console.warn("[ThemeContext] updateAppIcon failed (likely not registered yet)");
}
}
};
updateIcon();
}, [currentTheme]);
const theme = useMemo(() => getTheme(currentTheme), [currentTheme]); const theme = useMemo(() => getTheme(currentTheme), [currentTheme]);
return ( return (

View File

@@ -1 +0,0 @@
/home/narl/dev/ultimate-ban-tracker/ultimate-ban-tracker-1.3.3.tar.gz

View File

@@ -1,63 +0,0 @@
name: Build and Release
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 25
cache: 'npm'
cache-dependency-path: frontend/package.json
- name: Install Dependencies
run: |
dpkg --add-architecture i386
apt-get update
apt-get install -y wine64 wine32 imagemagick
cd frontend
npm install
- name: Prepare Icons
run: |
# Convert SVG to PNG for better compatibility
convert -background none -size 512x512 frontend/assets-build/icon.svg frontend/assets-build/icon.png
- name: Build Linux and Windows
run: |
cd frontend
# Building for Linux (AppImage, deb) and Windows (nsis)
npm run electron:build -- --linux --win
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELECTRON_BUILDER_ALLOW_EMPTY_REPOSITORY: true
- name: Extract Version
id: get_version
run: |
VERSION=$(node -p "require('./frontend/package.json').version")
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Upload Release Artifacts
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
with:
files: |
frontend/release/*.AppImage
frontend/release/*.deb
frontend/release/*.exe
tag_name: v${{ steps.get_version.outputs.VERSION }}
name: Release v${{ steps.get_version.outputs.VERSION }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,48 +0,0 @@
# Dependencies
**/node_modules/
/.pnp
.pnp.js
# Testing
**/coverage/
# Production / Build
**/dist/
**/build/
frontend/dist/
backend/dist/
# Misc
.DS_Store
*.pem
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
backend/.env
frontend/.env
# TypeScript
*.tsbuildinfo
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
# Docker
.docker/
# release files
release/

View File

@@ -1,25 +0,0 @@
PERSONAL USE AND NON-COMMERCIAL LICENSE
Copyright (c) 2026 Nils Pukropp. All rights reserved.
The "Ultimate Ban Tracker" software (the "Software") is the intellectual property of Nils Pukropp.
By using the Software, you agree to the following terms and conditions:
1. GRANT OF LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy of this Software, to use the Software for personal, non-commercial purposes only.
2. RESTRICTIONS
- You may not use this Software for any commercial purpose, including but not limited to selling, leasing, or using the Software as part of a paid service.
- You may not redistribute, sub-license, or sell copies of the Software.
- You may not modify, decompile, or reverse engineer the Software for commercial gain.
- You must maintain all copyright notices and branding within the Software.
3. INTELLECTUAL PROPERTY
All title, ownership rights, and intellectual property rights in and to the Software (including but not limited to any code, images, and documentation) are owned by Nils Pukropp.
4. TERMINATION
This license terminates automatically if you fail to comply with any of its terms.
5. DISCLAIMER
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 AUTHOR 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

@@ -1,47 +0,0 @@
# Ultimate Ban Tracker
Ultimate Ban Tracker is a standalone desktop application for Windows and Linux designed to manage multiple Steam accounts and monitor ban statuses. It features an automated account switcher and an optional community-driven synchronization system for sharing account access and cooldown data.
## Features
### Account Management
* **Credential-free Login**: Switch between Steam accounts instantly. The application extracts and injects authentication tokens directly into local Steam configuration files, bypassing the need for manual credential entry or Steam Guard codes on every switch.
* **Session Synchronization**: Sync login states across multiple machines via the optional community server. Authenticate once, and the session is available to authorized community members.
* **Auto-Discovery**: Automatically detects and imports accounts currently logged into the local Steam client.
### Monitoring and Tracking
* **Cooldown Tracking**: Scrapes Steam Personal Game Data (GCPD) to provide live countdowns for Counter-Strike competitive cooldowns.
* **Distributed Scraping**: Cooldown data is aggregated on the community server; only one active client is required to keep the status updated for all users tracking that account.
* **Ban Detection**: Real-time monitoring for VAC and developer game bans.
### User Interface
* **Dynamic Theming**: Includes several built-in color schemes including Steam Classic, Catppuccin (Mocha/Latte), Nord, and Tokyo Night.
* **Denisty-Focused Design**: Compact list view allows for monitoring a large number of accounts simultaneously.
## Installation
### For Users
Download the latest pre-built installer for your operating system from the [Releases](https://git.narl.io/nvrl/ultimate-ban-tracker/releases) page.
* **Windows**: Download the `.exe` installer.
* **Linux**: Download the `.AppImage` (portable) or `.deb` (Debian/Ubuntu) package.
### For Developers
Local development requires Node.js v22 or higher and an active Steam installation.
1. Clone the repository.
2. Navigate to the `frontend` directory.
3. Install dependencies: `npm install`.
4. Start the development environment: `npm run electron:dev`.
5. To build production binaries: `npm run electron:build`.
## Community Server
The backend server required for community features (sharing and sync) is hosted in a separate repository:
[Ultimate Ban Tracker Server](https://git.narl.io/nvrl/ultimate-ban-tracker-server)
## Security
* **Encryption**: All sensitive data, including Steam cookies and configuration blobs, are encrypted using AES-256-GCM before being synchronized with the backend.
* **Local Storage**: Account data is stored locally using `electron-store`. Usage of the community server is optional.
* **Session Isolation**: Steam authentication is performed in an isolated browser partition that is cleared after each session to prevent credential leakage.
## License
ISC License. Created by Nils Pukropp.

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -1,33 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1B2838"/>
<stop offset="100%" stop-color="#101419"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#66C0F4"/>
<stop offset="100%" stop-color="#1A9FFF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<!-- Outer Rounded Container -->
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<rect x="2" y="2" width="508" height="508" rx="98" stroke="white" stroke-opacity="0.05" stroke-width="4"/>
<!-- Tracking Ring (Detailed) -->
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<!-- Central Shield Symbol -->
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<!-- "Ban" Intersect (Stylized Cross) -->
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
<!-- Glass Highlight -->
<path d="M100 100C150 60 362 60 412 100" stroke="white" stroke-opacity="0.1" stroke-width="20" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#EFF1F5"/>
<stop offset="100%" stop-color="#DCE0E8"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1E66F5"/>
<stop offset="100%" stop-color="#179299"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#1E66F5" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#EFF1F5" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1E1E2E"/>
<stop offset="100%" stop-color="#11111B"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#B4BEFE"/>
<stop offset="100%" stop-color="#89B4FA"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#B4BEFE" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1E1E2E" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#3B4252"/>
<stop offset="100%" stop-color="#2E3440"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#88C0D0"/>
<stop offset="100%" stop-color="#81A1C1"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#88C0D0" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#3B4252" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1B2838"/>
<stop offset="100%" stop-color="#101419"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#66C0F4"/>
<stop offset="100%" stop-color="#1A9FFF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,21 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1A1B26"/>
<stop offset="100%" stop-color="#10101A"/>
</linearGradient>
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7AA2F7"/>
<stop offset="100%" stop-color="#3D59A1"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
<circle cx="256" cy="256" r="180" stroke="#7AA2F7" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
<path d="M210 220L302 312M302 220L210 312" stroke="#1A1B26" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,759 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const electron_1 = require("electron");
const path_1 = __importDefault(require("path"));
const electron_store_1 = __importDefault(require("electron-store"));
const child_process_1 = require("child_process");
const dotenv_1 = __importDefault(require("dotenv"));
const axios_1 = __importDefault(require("axios"));
const fs_1 = __importDefault(require("fs"));
const url_1 = require("url");
const steam_web_1 = require("./services/steam-web");
const scraper_1 = require("./services/scraper");
const steam_client_1 = require("./services/steam-client");
const backend_1 = require("./services/backend");
// Reliable isDev check
const isDev = !electron_1.app.isPackaged;
electron_1.app.name = "Ultimate Ban Tracker";
// Load environment variables
dotenv_1.default.config({ path: path_1.default.join(electron_1.app.getAppPath(), '..', '.env') });
// --- App State ---
let mainWindow = null;
let tray = null;
let backend = null;
electron_1.app.isQuitting = false;
const store = new electron_store_1.default({
defaults: {
accounts: [],
serverConfig: { url: 'https://ultimate-ban-tracker.narl.io', enabled: false }
}
});
// --- Avatar Cache Logic ---
const AVATAR_DIR = path_1.default.join(electron_1.app.getPath('userData'), 'avatars');
if (!fs_1.default.existsSync(AVATAR_DIR))
fs_1.default.mkdirSync(AVATAR_DIR, { recursive: true });
const downloadAvatar = async (steamId, url) => {
if (!url)
return undefined;
const localPath = path_1.default.join(AVATAR_DIR, `${steamId}.jpg`);
try {
const response = await axios_1.default.get(url, { responseType: 'arraybuffer', timeout: 5000 });
fs_1.default.writeFileSync(localPath, Buffer.from(response.data));
return localPath;
}
catch (e) {
return undefined;
}
};
// --- Backend ---
const initBackend = () => {
const config = store.get('serverConfig');
if (config && config.enabled && config.url) {
backend = new backend_1.BackendService(config.url, config.token);
}
else {
backend = null;
}
};
// --- System Tray ---
const createTray = () => {
// Try to find the icon in various standard locations
const possiblePaths = [
path_1.default.join(__dirname, '..', 'assets-build'), // Dev
path_1.default.join(process.resourcesPath, 'assets-build'), // Packaged (External)
path_1.default.join(electron_1.app.getAppPath(), 'dist', 'assets-build'), // Packaged (Internal dist)
path_1.default.join(electron_1.app.getAppPath(), 'assets-build') // Packaged (Internal root)
];
let assetsDir = '';
for (const p of possiblePaths) {
if (fs_1.default.existsSync(p)) {
assetsDir = p;
break;
}
}
const possibleIcons = ['icon.png', 'icon.svg'];
let iconPath = '';
if (assetsDir) {
for (const name of possibleIcons) {
const fullPath = path_1.default.join(assetsDir, name);
if (fs_1.default.existsSync(fullPath)) {
iconPath = fullPath;
break;
}
}
}
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
if (!iconPath) {
console.warn(`[Tray] FAILED: No valid icon found in searched paths.`);
return;
}
try {
const icon = electron_1.nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
tray = new electron_1.Tray(icon);
tray.setToolTip('Ultimate Ban Tracker');
tray.on('click', () => { if (mainWindow) {
mainWindow.show();
mainWindow.focus();
} });
// Load initial themed icon
const config = store.get('serverConfig');
if (config?.theme) {
setAppIcon(config.theme);
}
else {
updateTrayMenu(); // Fallback to refresh menu
}
}
catch (e) { }
};
const updateTrayMenu = () => {
if (!tray)
return;
const accounts = store.get('accounts');
const config = store.get('serverConfig');
const contextMenu = electron_1.Menu.buildFromTemplate([
{ label: `Ultimate Ban Tracker v${electron_1.app.getVersion()}`, enabled: false },
{ type: 'separator' },
{
label: 'Switch Account',
submenu: accounts.length > 0 ? accounts.map(acc => ({
label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`,
enabled: !!acc.loginName,
click: () => handleSwitchAccount(acc.loginName)
})) : [{ label: 'No accounts found', enabled: false }]
},
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
{ type: 'separator' },
{ label: 'Show Dashboard', click: () => { if (mainWindow)
mainWindow.show(); } },
{ label: 'Quit', click: () => { electron_1.app.isQuitting = true; electron_1.app.quit(); } }
]);
tray.setContextMenu(contextMenu);
};
const setAppIcon = (themeName = 'steam') => {
const assetsDir = path_1.default.join(__dirname, '..', 'assets-build', 'icons');
const iconPath = path_1.default.join(assetsDir, `${themeName}.svg`);
if (!fs_1.default.existsSync(iconPath))
return;
const icon = electron_1.nativeImage.createFromPath(iconPath);
// Update Tray
if (tray) {
tray.setImage(icon.resize({ width: 16, height: 16 }));
}
// Update Main Window
if (mainWindow) {
mainWindow.setIcon(icon);
}
};
// --- Steam Logic ---
const killSteam = async () => {
return new Promise((resolve) => {
const command = process.platform === 'win32' ? 'taskkill /f /im steam.exe' : 'pkill -9 steam';
(0, child_process_1.exec)(command, () => setTimeout(resolve, 1000));
});
};
const startSteam = () => {
const command = process.platform === 'win32' ? 'start steam://open/main' : 'steam &';
(0, child_process_1.exec)(command);
};
const handleSwitchAccount = async (loginName) => {
if (!loginName)
return false;
try {
await killSteam();
const accounts = store.get('accounts');
const account = accounts.find(a => a.loginName === loginName);
if (process.platform === 'win32') {
const regCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`;
const rememberCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v RememberPassword /t REG_DWORD /d 1 /f`;
await new Promise((res, rej) => (0, child_process_1.exec)(`${regCommand} && ${rememberCommand}`, (e) => e ? rej(e) : res()));
if (account && account.loginConfig)
steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig);
}
else if (process.platform === 'linux') {
await steam_client_1.steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam();
return true;
}
catch (e) {
return false;
}
};
// --- Scraper Helper ---
const scrapeAccountData = async (account) => {
const now = new Date();
try {
const profile = await (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName;
account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned;
account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath)
account.localAvatar = localPath;
}
if (account.steamLoginSecure) {
try {
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
account.authError = false;
account.lastScrapeTime = now.toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend)
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
}
else {
account.cooldownExpiresAt = undefined;
if (backend)
await backend.pushCooldown(account.steamId, undefined, now.toISOString());
}
}
catch (e) {
if (e instanceof scraper_1.SteamAuthError) {
account.authError = true;
}
else {
console.error(`[Scraper] Temporary error for ${account.personaName}: ${e.message}`);
}
}
}
if (backend && !account._id.startsWith('shared_')) {
await backend.shareAccount(account);
}
return true;
}
catch (e) {
console.error(`[Scraper] Failed to scrape ${account.personaName}:`, e);
return false;
}
};
// --- Sync Worker ---
const syncAccounts = async (isManual = false) => {
console.log(`[Sync] Phase 1: Pulling from server...`);
initBackend();
let accounts = store.get('accounts');
let hasChanges = false;
if (backend) {
try {
const shared = await backend.getSharedAccounts();
for (const s of shared) {
const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) {
accounts.push({
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName,
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned,
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure,
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt,
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
});
hasChanges = true;
}
else {
const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
// 1. SENSITIVE DATA SYNC (Credentials)
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
const isLocalAccount = !exists._id.startsWith('shared_');
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
// SMART OVERWRITE LOGIC:
// - If it's a remote shared account: Newest wins.
// - If it's a LOCAL account: Only overwrite if our local session is broken/missing.
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
if (shouldOverwriteCredentials) {
if (s.loginName)
exists.loginName = s.loginName;
if (s.loginConfig)
exists.loginConfig = s.loginConfig;
if (s.steamLoginSecure) {
exists.steamLoginSecure = s.steamLoginSecure;
exists.autoCheckCooldown = true;
exists.authError = false;
}
exists.sessionUpdatedAt = s.sessionUpdatedAt;
hasChanges = true;
}
// 2. Metadata Sync (Pull) - Always "Newest Wins"
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0);
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
if (sMetaDate > lMetaDate) {
exists.personaName = s.personaName;
exists.avatar = s.avatar;
exists.vacBanned = s.vacBanned;
exists.gameBans = s.gameBans;
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
exists.lastBanCheck = s.lastMetadataCheck;
hasChanges = true;
}
// Cooldown Sync (Pull)
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
if (sScrapeDate > lScrapeDate) {
exists.cooldownExpiresAt = s.cooldownExpiresAt;
exists.lastScrapeTime = s.lastScrapeTime;
hasChanges = true;
}
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) {
exists.sharedWith = s.sharedWith;
hasChanges = true;
}
}
}
}
catch (e) { }
}
if (hasChanges) {
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
}
// Phase 2: Background Scrapes
const runScrapes = async () => {
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
const currentAccounts = [...store.get('accounts')];
let scrapeChanges = false;
for (const account of currentAccounts) {
try {
const now = new Date();
if (backend && !account._id.startsWith('shared_'))
await backend.shareAccount(account);
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8;
if (needsMetadata || needsCooldown || isManual) {
if (!isManual && needsCooldown)
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000));
if (await scrapeAccountData(account))
scrapeChanges = true;
}
}
catch (error) { }
}
if (scrapeChanges) {
store.set('accounts', currentAccounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', currentAccounts);
updateTrayMenu();
}
console.log('[Sync] Sync cycle finished.');
};
if (isManual)
await runScrapes();
else
runScrapes();
};
const scheduleNextSync = () => {
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
};
// --- Discovery ---
const addingAccounts = new Set();
const handleLocalAccountsFound = async (localAccounts) => {
const currentAccounts = store.get('accounts');
let hasChanges = false;
for (const local of localAccounts) {
if (addingAccounts.has(local.steamId))
continue;
const exists = currentAccounts.find(a => a.steamId === local.steamId);
if (exists) {
if (!exists.loginName && local.accountName) {
exists.loginName = local.accountName;
hasChanges = true;
}
}
else {
addingAccounts.add(local.steamId);
try {
const profile = await (0, steam_web_1.fetchProfileData)(local.steamId);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl);
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
// Wait and retry snagging the config (Steam takes time to write it)
let loginConfig = undefined;
for (let i = 0; i < 3; i++) {
await new Promise(r => setTimeout(r, 2000));
loginConfig = steam_client_1.steamClient.extractAccountConfig(local.accountName);
if (loginConfig)
break;
}
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId, personaName: profile.personaName || local.accountName,
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
localAvatar: localPath, profileUrl: profile.profileUrl,
loginConfig, sessionUpdatedAt: loginConfig ? new Date().toISOString() : undefined,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
});
hasChanges = true;
}
catch (e) { }
addingAccounts.delete(local.steamId);
}
}
if (hasChanges) {
store.set('accounts', currentAccounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', currentAccounts);
updateTrayMenu();
}
};
// --- Main Window ---
function createWindow() {
mainWindow = new electron_1.BrowserWindow({
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
webPreferences: { preload: path_1.default.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true }
});
mainWindow.setMenu(null);
mainWindow.on('close', (event) => {
if (!electron_1.app.isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
return false;
});
if (isDev)
mainWindow.loadURL('http://localhost:5173');
else
mainWindow.loadFile(path_1.default.join(__dirname, '..', 'dist', 'index.html'));
}
electron_1.app.whenReady().then(() => {
electron_1.protocol.handle('steam-resource', (request) => {
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
if (process.platform !== 'win32' && !rawPath.startsWith('/'))
rawPath = '/' + rawPath;
const absolutePath = path_1.default.isAbsolute(rawPath) ? rawPath : path_1.default.resolve(rawPath);
if (!fs_1.default.existsSync(absolutePath))
return new Response('Not Found', { status: 404 });
try {
return electron_1.net.fetch((0, url_1.pathToFileURL)(absolutePath).toString());
}
catch (e) {
return new Response('Error', { status: 500 });
}
});
createWindow();
createTray();
initBackend();
setTimeout(() => syncAccounts(false), 5000);
scheduleNextSync();
steam_client_1.steamClient.startWatching(handleLocalAccountsFound);
});
electron_1.app.on('window-all-closed', () => { if (process.platform !== 'darwin' && electron_1.app.isQuitting)
electron_1.app.quit(); });
electron_1.app.on('activate', () => { if (electron_1.BrowserWindow.getAllWindows().length === 0)
createWindow();
else
mainWindow?.show(); });
// --- IPC Handlers ---
electron_1.ipcMain.handle('get-accounts', () => store.get('accounts'));
electron_1.ipcMain.handle('get-server-config', () => store.get('serverConfig'));
electron_1.ipcMain.handle('update-server-config', (event, config) => {
const current = store.get('serverConfig');
const updated = { ...current, ...config };
store.set('serverConfig', updated);
initBackend();
return updated;
});
electron_1.ipcMain.handle('login-to-server', async () => {
initBackend();
const config = store.get('serverConfig');
if (!config.url)
return false;
return new Promise((resolve) => {
const authWindow = new electron_1.BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false;
const saveServerAuth = (token) => {
if (captured)
return;
captured = true;
let serverSteamId = undefined;
let isAdmin = false;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
serverSteamId = payload.steamId;
isAdmin = !!payload.isAdmin;
}
catch (e) { }
const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
initBackend();
authWindow.close();
resolve(true);
};
const filter = { urls: [`${config.url}/*`] };
authWindow.webContents.session.webRequest.onHeadersReceived(filter, (details, callback) => {
const headers = details.responseHeaders || {};
const authToken = headers['x-ubt-auth-token']?.[0] || headers['X-UBT-Auth-Token']?.[0];
if (authToken)
saveServerAuth(authToken);
callback({ cancel: false });
});
authWindow.on('page-title-updated', (event, title) => {
if (title.includes('AUTH_TOKEN:')) {
const token = title.split('AUTH_TOKEN:')[1];
if (token)
saveServerAuth(token);
}
});
authWindow.on('closed', () => resolve(false));
});
});
electron_1.ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId }));
electron_1.ipcMain.handle('sync-now', async () => { await syncAccounts(true); return true; });
electron_1.ipcMain.handle('scrape-account', async (event, steamId) => {
const accounts = store.get('accounts');
const account = accounts.find(a => a.steamId === steamId);
if (!account)
return false;
const success = await scrapeAccountData(account);
if (success) {
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
}
return success;
});
electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
try {
initBackend();
if (backend) {
const shared = await backend.getCommunityAccounts();
const existing = shared.find((s) => s.steamId === identifier || s.profileUrl.includes(identifier));
if (existing) {
const accounts = store.get('accounts');
if (accounts.find(a => a.steamId === existing.steamId))
throw new Error('Account already tracked');
const newAccount = {
_id: `shared_${existing.steamId}`, steamId: existing.steamId, personaName: existing.personaName,
avatar: existing.avatar, profileUrl: existing.profileUrl, vacBanned: existing.vacBanned,
gameBans: existing.gameBans, cooldownExpiresAt: existing.cooldownExpiresAt,
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith
};
store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
}
}
const profile = await (0, steam_web_1.fetchProfileData)(identifier);
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl);
const localAvatar = await downloadAvatar(profile.steamId, profile.avatar);
const accounts = store.get('accounts');
const newAccount = {
_id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName,
loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl,
autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
}
catch (error) {
throw error;
}
});
electron_1.ipcMain.handle('update-account', (event, id, data) => {
const accounts = store.get('accounts');
const index = accounts.findIndex((a) => a._id === id);
if (index !== -1) {
accounts[index] = { ...accounts[index], ...data };
store.set('accounts', accounts);
updateTrayMenu();
return accounts[index];
}
return null;
});
electron_1.ipcMain.handle('delete-account', (event, id) => {
const accounts = store.get('accounts');
store.set('accounts', accounts.filter((a) => a._id !== id));
updateTrayMenu();
return true;
});
electron_1.ipcMain.handle('share-account-with-user', async (event, steamId, targetSteamId) => {
initBackend();
if (backend) {
const accounts = store.get('accounts');
const account = accounts.find(a => a.steamId === steamId);
if (account)
await backend.shareAccount(account);
return await backend.shareWithUser(steamId, targetSteamId);
}
throw new Error('Backend not configured');
});
electron_1.ipcMain.handle('revoke-account-access', async (event, steamId, targetSteamId) => {
initBackend();
if (backend)
return await backend.revokeAccess(steamId, targetSteamId);
throw new Error('Backend not configured');
});
electron_1.ipcMain.handle('revoke-all-account-access', async (event, steamId) => {
initBackend();
if (backend)
return await backend.revokeAllAccess(steamId);
throw new Error('Backend not configured');
});
electron_1.ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
electron_1.ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
// --- Admin IPC ---
electron_1.ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; });
electron_1.ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
electron_1.ipcMain.handle('admin-delete-user', async (event, userId) => { initBackend(); if (backend)
await backend.deleteUser(userId); return true; });
electron_1.ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
electron_1.ipcMain.handle('admin-remove-account', async (event, steamId) => { initBackend(); if (backend)
await backend.forceRemoveAccount(steamId); return true; });
electron_1.ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
electron_1.ipcMain.handle('update-app-icon', (event, themeName) => {
setAppIcon(themeName);
return true;
});
electron_1.ipcMain.handle('switch-account', async (event, loginName) => {
if (!loginName)
return false;
try {
// PROACTIVE SYNC: Try to snag the freshest token before we kill Steam
const accounts = store.get('accounts');
const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steam_client_1.steamClient.extractAccountConfig(loginName);
if (freshConfig) {
account.loginConfig = freshConfig;
account.sessionUpdatedAt = new Date().toISOString();
if (backend)
await backend.shareAccount(account);
store.set('accounts', accounts);
}
}
await killSteam();
if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
const commands = [
`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,
`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,
`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,
`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`
];
await new Promise((res, rej) => (0, child_process_1.exec)(commands.join(' && '), (e) => e ? rej(e) : res()));
if (account && account.loginConfig)
steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig);
}
else if (process.platform === 'linux') {
await steam_client_1.steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam();
return true;
}
catch (e) {
return false;
}
});
electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url));
electron_1.ipcMain.handle('open-steam-app-login', async () => {
await killSteam();
if (process.platform === 'win32') {
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
await new Promise((res) => (0, child_process_1.exec)(clearReg, () => res()));
}
else if (process.platform === 'linux') {
await steam_client_1.steamClient.setAutoLoginUser("", undefined, "");
}
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
(0, child_process_1.exec)(command);
return true;
});
electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => {
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
const loginSession = electron_1.session.fromPartition(partitionId);
if (!expectedSteamId)
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
if (expectedSteamId) {
const accounts = store.get('accounts');
const account = accounts.find(a => a.steamId === expectedSteamId);
if (account?.steamLoginSecure) {
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
for (const pair of cookiePairs) {
const [name, value] = pair.split('=');
if (name && value) {
try {
await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') });
}
catch (e) { }
}
}
}
}
return new Promise((resolve) => {
const loginWindow = new electron_1.BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
});
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
const checkCookie = setInterval(async () => {
try {
const cookies = await loginSession.cookies.get({ domain: 'steamcommunity.com' });
const secureCookie = cookies.find(c => c.name === 'steamLoginSecure');
if (secureCookie) {
const steamId = decodeURIComponent(secureCookie.value).split('|')[0];
if (steamId) {
if (expectedSteamId && steamId !== expectedSteamId)
return;
clearInterval(checkCookie);
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const accounts = store.get('accounts');
const accountIndex = accounts.findIndex(a => a.steamId === steamId);
if (accountIndex !== -1) {
const account = accounts[accountIndex];
account.steamLoginSecure = cookieString;
account.autoCheckCooldown = true;
account.authError = false;
account.sessionUpdatedAt = new Date().toISOString();
if (account.loginName) {
const config = steam_client_1.steamClient.extractAccountConfig(account.loginName);
if (config)
account.loginConfig = config;
}
try {
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, cookieString);
account.lastScrapeTime = new Date().toISOString();
account.cooldownExpiresAt = result.isActive && result.expiresAt ? result.expiresAt.toISOString() : undefined;
}
catch (e) { }
initBackend();
if (backend)
await backend.shareAccount(account);
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
loginWindow.close();
resolve(true);
}
}
}
}
catch (error) { }
}, 1000);
loginWindow.on('closed', () => { clearInterval(checkCookie); resolve(false); });
});
});

File diff suppressed because one or more lines are too long

View File

@@ -1,38 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const electron_1 = require("electron");
electron_1.contextBridge.exposeInMainWorld('electronAPI', {
getAccounts: () => electron_1.ipcRenderer.invoke('get-accounts'),
addAccount: (account) => electron_1.ipcRenderer.invoke('add-account', account),
updateAccount: (id, data) => electron_1.ipcRenderer.invoke('update-account', id, data),
deleteAccount: (id) => electron_1.ipcRenderer.invoke('delete-account', id),
switchAccount: (loginName) => electron_1.ipcRenderer.invoke('switch-account', loginName),
shareAccountWithUser: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('share-account-with-user', steamId, targetSteamId),
revokeAccountAccess: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
revokeAllAccountAccess: (steamId) => electron_1.ipcRenderer.invoke('revoke-all-account-access', steamId),
openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', url),
updateAppIcon: (theme) => electron_1.ipcRenderer.invoke('update-app-icon', theme),
openSteamAppLogin: () => electron_1.ipcRenderer.invoke('open-steam-app-login'),
openSteamLogin: (steamId) => electron_1.ipcRenderer.invoke('open-steam-login', steamId),
// Server Config & Auth
getServerConfig: () => electron_1.ipcRenderer.invoke('get-server-config'),
updateServerConfig: (config) => electron_1.ipcRenderer.invoke('update-server-config', config),
loginToServer: () => electron_1.ipcRenderer.invoke('login-to-server'),
getServerUserInfo: () => electron_1.ipcRenderer.invoke('get-server-user-info'),
syncNow: () => electron_1.ipcRenderer.invoke('sync-now'),
scrapeAccount: (steamId) => electron_1.ipcRenderer.invoke('scrape-account', steamId),
getCommunityAccounts: () => electron_1.ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => electron_1.ipcRenderer.invoke('get-server-users'),
// Admin API
adminGetStats: () => electron_1.ipcRenderer.invoke('admin-get-stats'),
adminGetUsers: () => electron_1.ipcRenderer.invoke('admin-get-users'),
adminDeleteUser: (userId) => electron_1.ipcRenderer.invoke('admin-delete-user', userId),
adminGetAccounts: () => electron_1.ipcRenderer.invoke('admin-get-accounts'),
adminRemoveAccount: (steamId) => electron_1.ipcRenderer.invoke('admin-remove-account', steamId),
onAccountsUpdated: (callback) => {
const subscription = (_event, accounts) => callback(accounts);
electron_1.ipcRenderer.on('accounts-updated', subscription);
return () => electron_1.ipcRenderer.removeListener('accounts-updated', subscription);
},
platform: process.platform
});

View File

@@ -1 +0,0 @@
{"version":3,"file":"preload.js","sourceRoot":"","sources":["../electron/preload.ts"],"names":[],"mappings":";AAAA,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAE3D,aAAa,CAAC,iBAAiB,CAAC,aAAa,EAAE;IAC7C,WAAW,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,cAAc,CAAC;IACrD,UAAU,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,aAAa,EAAE,OAAO,CAAC;IACnE,aAAa,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,gBAAgB,EAAE,EAAE,EAAE,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,gBAAgB,EAAE,EAAE,CAAC;IAC/D,aAAa,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,gBAAgB,EAAE,SAAS,CAAC;IAC7E,YAAY,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,eAAe,EAAE,GAAG,CAAC;IAC/D,iBAAiB,EAAE,CAAC,QAAQ,EAAE,EAAE;QAC9B,WAAW,CAAC,EAAE,CAAC,kBAAkB,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC/E,CAAC;IACD,QAAQ,EAAE,OAAO,CAAC,QAAQ;CAC3B,CAAC,CAAC"}

View File

@@ -1,192 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BackendService = void 0;
const axios_1 = __importDefault(require("axios"));
class BackendService {
url;
token;
constructor(url, token) {
this.url = url;
this.token = token;
}
get headers() {
return {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
};
}
async getSharedAccounts() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/sync`, { headers: this.headers });
return response.data;
}
catch (e) {
console.error('[Backend] Failed to fetch shared accounts');
return [];
}
}
async getCommunityAccounts() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/sync/community`, { headers: this.headers });
return response.data;
}
catch (e) {
console.error('[Backend] Failed to fetch community accounts');
return [];
}
}
async getServerUsers() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/sync/users`, { headers: this.headers });
return response.data;
}
catch (e) {
console.error('[Backend] Failed to fetch server users');
return [];
}
}
async shareAccount(account) {
if (!this.token)
return;
try {
await axios_1.default.post(`${this.url}/api/sync`, {
steamId: account.steamId,
personaName: account.personaName,
avatar: account.avatar,
profileUrl: account.profileUrl,
vacBanned: account.vacBanned,
gameBans: account.gameBans,
loginName: account.loginName,
steamLoginSecure: account.steamLoginSecure,
loginConfig: account.loginConfig,
sessionUpdatedAt: account.sessionUpdatedAt,
lastMetadataCheck: account.lastBanCheck,
lastScrapeTime: account.lastScrapeTime,
cooldownExpiresAt: account.cooldownExpiresAt
}, { headers: this.headers });
}
catch (e) {
console.error('[Backend] Failed to share account');
}
}
async pushCooldown(steamId, cooldownExpiresAt, lastScrapeTime) {
if (!this.token)
return;
try {
await axios_1.default.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt,
lastScrapeTime
}, { headers: this.headers });
}
catch (e) {
console.error(`[Backend] Failed to push cooldown for ${steamId}`);
}
}
async shareWithUser(steamId, targetSteamId) {
if (!this.token)
return;
try {
const response = await axios_1.default.post(`${this.url}/api/sync/${steamId}/share`, {
targetSteamId
}, { headers: this.headers });
return response.data;
}
catch (e) {
console.error(`[Backend] Failed to share account ${steamId} with ${targetSteamId}`);
throw new Error(e.response?.data?.message || 'Failed to share account');
}
}
async revokeAccess(steamId, targetSteamId) {
if (!this.token)
return;
try {
const response = await axios_1.default.delete(`${this.url}/api/sync/${steamId}/share`, {
headers: this.headers,
data: { targetSteamId }
});
return response.data;
}
catch (e) {
console.error(`[Backend] Failed to revoke access for ${steamId} from ${targetSteamId}`);
throw new Error(e.response?.data?.message || 'Failed to revoke access');
}
}
async revokeAllAccess(steamId) {
if (!this.token)
return;
try {
const response = await axios_1.default.delete(`${this.url}/api/sync/${steamId}/share/all`, {
headers: this.headers
});
return response.data;
}
catch (e) {
console.error(`[Backend] Failed to revoke all access for ${steamId}`);
throw new Error(e.response?.data?.message || 'Failed to revoke all access');
}
}
// --- Admin API ---
async getAdminStats() {
if (!this.token)
return null;
try {
const response = await axios_1.default.get(`${this.url}/api/admin/stats`, { headers: this.headers });
return response.data;
}
catch (e) {
return null;
}
}
async getAdminUsers() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/admin/users`, { headers: this.headers });
return response.data;
}
catch (e) {
return [];
}
}
async deleteUser(userId) {
if (!this.token)
return;
try {
await axios_1.default.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
}
catch (e) {
throw new Error(e.response?.data?.message || 'Failed to delete user');
}
}
async getAdminAccounts() {
if (!this.token)
return [];
try {
const response = await axios_1.default.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
return response.data;
}
catch (e) {
return [];
}
}
async forceRemoveAccount(steamId) {
if (!this.token)
return;
try {
await axios_1.default.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
}
catch (e) {
throw new Error(e.response?.data?.message || 'Failed to remove account');
}
}
}
exports.BackendService = BackendService;

View File

@@ -1,104 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.scrapeCooldown = exports.SteamAuthError = void 0;
const axios_1 = __importDefault(require("axios"));
const cheerio = __importStar(require("cheerio"));
// Custom error to identify session death
class SteamAuthError extends Error {
constructor(message) {
super(message);
this.name = "SteamAuthError";
}
}
exports.SteamAuthError = SteamAuthError;
const scrapeCooldown = async (steamId, steamLoginSecure) => {
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
try {
const response = await axios_1.default.get(url, {
headers: {
'Cookie': steamLoginSecure,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000,
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
});
// If Steam redirects us to the login page, the cookie is dead
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
}
const $ = cheerio.load(response.data);
if (!response.data.includes('Personal Game Data')) {
throw new SteamAuthError('Session invalid: Personal Game Data not accessible');
}
let expirationDate = undefined;
$('table').each((_, table) => {
const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get();
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration'));
if (expirationIndex !== -1) {
const rows = $(table).find('tr').not(':has(th)');
rows.each((_, row) => {
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
if (dateText && dateText !== '') {
const cleanDateText = dateText.replace(' GMT', ' UTC');
const parsed = new Date(cleanDateText);
if (!isNaN(parsed.getTime())) {
if (!expirationDate || parsed > expirationDate)
expirationDate = parsed;
}
}
});
}
});
if (expirationDate && expirationDate.getTime() > Date.now()) {
return { isActive: true, expiresAt: expirationDate };
}
const content = $('#personal_game_data_content').text();
if (content.includes('Competitive Cooldown') || content.includes('Your account is currently')) {
return { isActive: true };
}
return { isActive: false };
}
catch (error) {
if (error instanceof SteamAuthError)
throw error;
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message);
throw error; // Generic errors don't trigger re-auth
}
};
exports.scrapeCooldown = scrapeCooldown;

View File

@@ -1 +0,0 @@
{"version":3,"file":"scraper.js","sourceRoot":"","sources":["../../electron/services/scraper.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,kDAA0B;AAC1B,iDAAmC;AAO5B,MAAM,cAAc,GAAG,KAAK,EAAE,OAAe,EAAE,gBAAwB,EAAyB,EAAE;IACvG,MAAM,GAAG,GAAG,uCAAuC,OAAO,2BAA2B,CAAC;IAEtF,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YACpC,OAAO,EAAE;gBACP,QAAQ,EAAE,oBAAoB,gBAAgB,EAAE;gBAChD,YAAY,EAAE,iHAAiH;aAChI;YACD,OAAO,EAAE,KAAK;SACf,CAAC,CAAC;QAEH,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAEtC,IAAI,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC;YACvF,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;QAChE,CAAC;QAED,MAAM,OAAO,GAAG,CAAC,CAAC,6BAA6B,CAAC,CAAC,IAAI,EAAE,CAAC;QACxD,MAAM,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,2BAA2B,CAAC,CAAC;QAE9G,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;QAC7B,CAAC;QAED,MAAM,WAAW,GAAG,CAAC,CAAC,mEAAmE,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;QAEzG,OAAO;YACL,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,WAAW,IAAI,iBAAiB;SACzC,CAAC;IACJ,CAAC;IAAC,OAAO,KAAU,EAAE,CAAC;QACpB,OAAO,CAAC,KAAK,CAAC,uBAAuB,OAAO,GAAG,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QAChE,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AAnCW,QAAA,cAAc,kBAmCzB"}

View File

@@ -1,254 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.steamClient = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const simple_vdf_1 = require("simple-vdf");
const chokidar_1 = __importDefault(require("chokidar"));
class SteamClientService {
steamPath = null;
onAccountsChanged = null;
constructor() {
this.detectSteamPath();
}
detectSteamPath() {
const platform = os_1.default.platform();
const home = os_1.default.homedir();
if (platform === 'win32') {
const possiblePaths = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
path_1.default.join(process.env.APPDATA || '', 'Steam'),
];
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
}
else if (platform === 'linux') {
const possiblePaths = [
path_1.default.join(home, '.steam/steam'),
path_1.default.join(home, '.local/share/Steam'),
path_1.default.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak
path_1.default.join(home, 'snap/steam/common/.steam/steam'), // Snap
];
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
}
if (this.steamPath) {
console.log(`[SteamClient] Detected Steam path: ${this.steamPath}`);
}
}
getLoginUsersPath() {
if (!this.steamPath)
return null;
return path_1.default.join(this.steamPath, 'config', 'loginusers.vdf');
}
getConfigVdfPath() {
if (!this.steamPath)
return null;
return path_1.default.join(this.steamPath, 'config', 'config.vdf');
}
safeWriteVdf(filePath, data) {
const tempPath = `${filePath}.tmp_${Date.now()}`;
const dir = path_1.default.dirname(filePath);
try {
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
const vdfContent = (0, simple_vdf_1.stringify)(data);
fs_1.default.writeFileSync(tempPath, vdfContent, 'utf-8');
fs_1.default.renameSync(tempPath, filePath);
}
catch (e) {
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
if (fs_1.default.existsSync(tempPath))
fs_1.default.unlinkSync(tempPath);
throw e;
}
}
startWatching(callback) {
this.onAccountsChanged = callback;
const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath && fs_1.default.existsSync(loginUsersPath)) {
this.readLocalAccounts();
chokidar_1.default.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => {
this.readLocalAccounts();
});
}
}
readLocalAccounts() {
const filePath = this.getLoginUsersPath();
if (!filePath || !fs_1.default.existsSync(filePath))
return;
try {
const content = fs_1.default.readFileSync(filePath, 'utf-8');
if (!content.trim())
return;
const data = (0, simple_vdf_1.parse)(content);
if (!data || !data.users)
return;
const accounts = [];
for (const [steamId64, userData] of Object.entries(data.users)) {
const user = userData;
if (!user || !user.AccountName)
continue;
accounts.push({
steamId: steamId64, accountName: user.AccountName,
personaName: user.PersonaName || user.AccountName,
timestamp: parseInt(user.Timestamp) || 0
});
}
if (this.onAccountsChanged)
this.onAccountsChanged(accounts);
}
catch (error) { }
}
extractAccountConfig(accountName) {
const configPath = this.getConfigVdfPath();
if (!configPath || !fs_1.default.existsSync(configPath))
return null;
try {
const content = fs_1.default.readFileSync(configPath, 'utf-8');
const data = (0, simple_vdf_1.parse)(content);
const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts;
return (accounts && accounts[accountName]) ? accounts[accountName] : null;
}
catch (e) {
return null;
}
}
injectAccountConfig(accountName, accountData) {
const configPath = this.getConfigVdfPath();
if (!configPath)
return;
let data = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } };
if (fs_1.default.existsSync(configPath)) {
try {
const content = fs_1.default.readFileSync(configPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content);
if (parsed && typeof parsed === 'object')
data = parsed;
}
catch (e) { }
}
const ensurePath = (obj, keys) => {
let curr = obj;
for (const key of keys) {
if (!curr[key] || typeof curr[key] !== 'object')
curr[key] = {};
curr = curr[key];
}
return curr;
};
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']);
// FAILPROOF: Force crucial flags that Steam uses to decide session validity
steamAccounts[accountName] = {
...accountData,
RememberPassword: "1",
AllowAutoLogin: "1",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
try {
this.safeWriteVdf(configPath, data);
console.log(`[SteamClient] Safely injected session for ${accountName}`);
}
catch (e) { }
}
async setAutoLoginUser(accountName, accountConfig, steamId) {
const platform = os_1.default.platform();
const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath) {
let data = { users: {} };
if (fs_1.default.existsSync(loginUsersPath)) {
try {
const content = fs_1.default.readFileSync(loginUsersPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content);
if (parsed && parsed.users)
data = parsed;
}
catch (e) { }
}
if (!data.users)
data.users = {};
let found = false;
for (const [id, user] of Object.entries(data.users)) {
const u = user;
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) {
u.mostrecent = "1";
u.RememberPassword = "1";
u.AllowAutoLogin = "1";
u.WantsOfflineMode = "0";
u.SkipOfflineModeWarning = "1";
u.WasNonInteractive = "0";
found = true;
}
else {
u.mostrecent = "0";
}
}
if (!found && steamId && accountName) {
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`);
data.users[steamId] = {
AccountName: accountName,
PersonaName: accountName,
RememberPassword: "1",
mostrecent: "1",
AllowAutoLogin: "1",
WantsOfflineMode: "0",
SkipOfflineModeWarning: "1",
WasNonInteractive: "0",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
}
try {
this.safeWriteVdf(loginUsersPath, data);
}
catch (e) { }
}
// Injection of the actual authentication blob
if (accountConfig && accountName) {
this.injectAccountConfig(accountName, accountConfig);
}
// --- Linux Registry / Registry.vdf Hardening ---
if (platform === 'linux') {
const regLocations = [
path_1.default.join(os_1.default.homedir(), '.steam', 'registry.vdf'),
path_1.default.join(os_1.default.homedir(), '.steam', 'steam', 'registry.vdf')
];
for (const regPath of regLocations) {
if (!fs_1.default.existsSync(path_1.default.dirname(regPath)))
continue;
let regData = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
if (fs_1.default.existsSync(regPath)) {
try {
const content = fs_1.default.readFileSync(regPath, 'utf-8');
const parsed = (0, simple_vdf_1.parse)(content);
if (parsed && typeof parsed === 'object')
regData = parsed;
}
catch (e) { }
}
const ensurePath = (obj, keys) => {
let curr = obj;
for (const key of keys) {
if (!curr[key] || typeof curr[key] !== 'object')
curr[key] = {};
curr = curr[key];
}
return curr;
};
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']);
steamKey.AutoLoginUser = accountName;
steamKey.RememberPassword = "1";
steamKey.AlreadyLoggedIn = "1";
steamKey.WantsOfflineMode = "0";
try {
this.safeWriteVdf(regPath, regData);
}
catch (e) { }
}
}
return true;
}
}
exports.steamClient = new SteamClientService();

View File

@@ -1,111 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.scrapeBanStatus = exports.fetchProfileData = void 0;
const axios_1 = __importDefault(require("axios"));
const cheerio = __importStar(require("cheerio"));
const AXIOS_CONFIG = {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
};
const fetchProfileData = async (identifier, steamLoginSecure) => {
let url = '';
// Clean identifier
const cleanId = identifier.replace(/https?:\/\/steamcommunity\.com\/(profiles|id)\//, '').replace(/\/$/, '');
if (cleanId.match(/^\d+$/)) {
url = `https://steamcommunity.com/profiles/${cleanId}?xml=1`;
}
else {
url = `https://steamcommunity.com/id/${cleanId}?xml=1`;
}
const headers = { ...AXIOS_CONFIG.headers };
if (steamLoginSecure) {
headers['Cookie'] = steamLoginSecure;
}
try {
const response = await axios_1.default.get(url, { ...AXIOS_CONFIG, headers });
const $ = cheerio.load(response.data, { xmlMode: true });
const steamId = $('steamID64').first().text().trim();
const personaName = $('steamID').first().text().trim();
const avatarRaw = $('avatarFull').first().text().trim();
// Robustly extract the first URL if concatenated
let avatar = avatarRaw;
const urls = avatarRaw.match(/https?:\/\/[^\s"'<>]+/g);
if (urls && urls.length > 0) {
avatar = urls[0];
}
// Ensure https
if (avatar && avatar.startsWith('http:')) {
avatar = avatar.replace('http:', 'https:');
}
const profileUrl = steamId
? `https://steamcommunity.com/profiles/${steamId}`
: (cleanId.match(/^\d+$/) ? `https://steamcommunity.com/profiles/${cleanId}` : `https://steamcommunity.com/id/${cleanId}`);
return {
steamId: steamId || cleanId,
personaName: personaName || 'Unknown',
avatar: avatar || 'https://avatars.akamai.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
profileUrl
};
}
catch (error) {
throw new Error(`Failed to fetch profile: ${error.message}`);
}
};
exports.fetchProfileData = fetchProfileData;
const scrapeBanStatus = async (profileUrl, steamLoginSecure) => {
try {
const headers = { ...AXIOS_CONFIG.headers };
if (steamLoginSecure) {
headers['Cookie'] = steamLoginSecure;
}
const response = await axios_1.default.get(profileUrl, { ...AXIOS_CONFIG, headers });
const $ = cheerio.load(response.data);
const banText = $('.profile_ban').text().toLowerCase();
const vacBanned = banText.includes('vac ban');
const gameBansMatch = banText.match(/(\d+)\s+game\s+ban/);
const gameBans = gameBansMatch ? parseInt(gameBansMatch[1]) : 0;
return { vacBanned, gameBans };
}
catch (error) {
return { vacBanned: false, gameBans: 0 };
}
};
exports.scrapeBanStatus = scrapeBanStatus;

View File

@@ -1,47 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveVanityURL = exports.getPlayerBans = exports.getPlayerSummaries = void 0;
const axios_1 = __importDefault(require("axios"));
const BASE_URL = 'https://api.steampowered.com';
const getPlayerSummaries = async (apiKey, steamIds) => {
if (!apiKey)
throw new Error('STEAM_API_KEY is not defined');
const response = await axios_1.default.get(`${BASE_URL}/ISteamUser/GetPlayerSummaries/v2/`, {
params: {
key: apiKey,
steamids: steamIds.join(',')
}
});
return response.data.response.players;
};
exports.getPlayerSummaries = getPlayerSummaries;
const getPlayerBans = async (apiKey, steamIds) => {
if (!apiKey)
throw new Error('STEAM_API_KEY is not defined');
const response = await axios_1.default.get(`${BASE_URL}/ISteamUser/GetPlayerBans/v1/`, {
params: {
key: apiKey,
steamids: steamIds.join(',')
}
});
return response.data.players;
};
exports.getPlayerBans = getPlayerBans;
const resolveVanityURL = async (apiKey, vanityUrl) => {
if (!apiKey)
throw new Error('STEAM_API_KEY is not defined');
const response = await axios_1.default.get(`${BASE_URL}/ISteamUser/ResolveVanityURL/v1/`, {
params: {
key: apiKey,
vanityurl: vanityUrl
}
});
if (response.data.response.success === 1) {
return response.data.response.steamid;
}
return null;
};
exports.resolveVanityURL = resolveVanityURL;

View File

@@ -1 +0,0 @@
{"version":3,"file":"steam.js","sourceRoot":"","sources":["../../electron/services/steam.ts"],"names":[],"mappings":";;;;;;AAAA,kDAA0B;AAE1B,MAAM,QAAQ,GAAG,8BAA8B,CAAC;AAqBzC,MAAM,kBAAkB,GAAG,KAAK,EAAE,MAAc,EAAE,QAAkB,EAAiC,EAAE;IAC5G,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,GAAG,CAAC,GAAG,QAAQ,oCAAoC,EAAE;QAChF,MAAM,EAAE;YACN,GAAG,EAAE,MAAM;YACX,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC;SAC7B;KACF,CAAC,CAAC;IACH,OAAO,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC;AACxC,CAAC,CAAC;AATW,QAAA,kBAAkB,sBAS7B;AAEK,MAAM,aAAa,GAAG,KAAK,EAAE,MAAc,EAAE,QAAkB,EAA6B,EAAE;IACnG,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,GAAG,CAAC,GAAG,QAAQ,+BAA+B,EAAE;QAC3E,MAAM,EAAE;YACN,GAAG,EAAE,MAAM;YACX,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC;SAC7B;KACF,CAAC,CAAC;IACH,OAAO,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC;AAC/B,CAAC,CAAC;AATW,QAAA,aAAa,iBASxB;AAEK,MAAM,gBAAgB,GAAG,KAAK,EAAE,MAAc,EAAE,SAAiB,EAA0B,EAAE;IAClG,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,GAAG,CAAC,GAAG,QAAQ,kCAAkC,EAAE;QAC9E,MAAM,EAAE;YACN,GAAG,EAAE,MAAM;YACX,SAAS,EAAE,SAAS;SACrB;KACF,CAAC,CAAC;IACH,IAAI,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,KAAK,CAAC,EAAE,CAAC;QACzC,OAAO,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC;IACxC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAZW,QAAA,gBAAgB,oBAY3B"}

View File

@@ -1,740 +0,0 @@
import { app, BrowserWindow, ipcMain, shell, session, protocol, net, Tray, Menu, nativeImage } from 'electron';
import path from 'path';
import Store from 'electron-store';
import { exec } from 'child_process';
import dotenv from 'dotenv';
import axios from 'axios';
import fs from 'fs';
import { pathToFileURL } from 'url';
import { fetchProfileData, scrapeBanStatus } from './services/steam-web';
import { scrapeCooldown, SteamAuthError } from './services/scraper';
import { steamClient, LocalSteamAccount } from './services/steam-client';
import { BackendService } from './services/backend';
// Reliable isDev check
const isDev = !app.isPackaged;
app.name = "Ultimate Ban Tracker";
// Load environment variables
dotenv.config({ path: path.join(app.getAppPath(), '..', '.env') });
// --- Types & Interfaces ---
interface Account {
_id: string;
steamId: string;
personaName: string;
loginName: string;
steamLoginSecure?: string;
loginConfig?: any;
sessionUpdatedAt?: string;
autoCheckCooldown: boolean;
avatar: string;
localAvatar?: string;
profileUrl: string;
status: string;
vacBanned: boolean;
gameBans: number;
lastBanCheck: string;
lastScrapeTime?: string;
cooldownExpiresAt?: string;
authError?: boolean;
notes?: string;
sharedWith?: any[];
}
interface ServerConfig {
url: string;
token?: string;
serverSteamId?: string;
enabled: boolean;
theme?: string;
isAdmin?: boolean;
}
// --- App State ---
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let backend: BackendService | null = null;
(app as any).isQuitting = false;
const store = new Store<{ accounts: Account[], serverConfig: ServerConfig }>({
defaults: {
accounts: [],
serverConfig: { url: 'https://ultimate-ban-tracker.narl.io', enabled: false }
}
}) as any;
// --- Avatar Cache Logic ---
const AVATAR_DIR = path.join(app.getPath('userData'), 'avatars');
if (!fs.existsSync(AVATAR_DIR)) fs.mkdirSync(AVATAR_DIR, { recursive: true });
const downloadAvatar = async (steamId: string, url: string): Promise<string | undefined> => {
if (!url) return undefined;
const localPath = path.join(AVATAR_DIR, `${steamId}.jpg`);
try {
const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 5000 });
fs.writeFileSync(localPath, Buffer.from(response.data));
return localPath;
} catch (e) {
return undefined;
}
};
// --- Backend ---
const initBackend = () => {
const config = store.get('serverConfig');
if (config && config.enabled && config.url) {
backend = new BackendService(config.url, config.token);
} else {
backend = null;
}
};
// --- System Tray ---
const createTray = () => {
// Try to find the icon in various standard locations
const possiblePaths = [
path.join(__dirname, '..', 'assets-build'), // Dev
path.join(process.resourcesPath, 'assets-build'), // Packaged (External)
path.join(app.getAppPath(), 'dist', 'assets-build'), // Packaged (Internal dist)
path.join(app.getAppPath(), 'assets-build') // Packaged (Internal root)
];
let assetsDir = '';
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
assetsDir = p;
break;
}
}
const possibleIcons = ['icon.png', 'icon.svg'];
let iconPath = '';
if (assetsDir) {
for (const name of possibleIcons) {
const fullPath = path.join(assetsDir, name);
if (fs.existsSync(fullPath)) {
iconPath = fullPath;
break;
}
}
}
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
if (!iconPath) {
console.warn(`[Tray] FAILED: No valid icon found in searched paths.`);
return;
}
try {
const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
tray = new Tray(icon);
tray.setToolTip('Ultimate Ban Tracker');
tray.on('click', () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } });
// Load initial themed icon
const config = store.get('serverConfig');
if (config?.theme) {
setAppIcon(config.theme);
} else {
updateTrayMenu(); // Fallback to refresh menu
}
} catch (e) { }
};
const updateTrayMenu = () => {
if (!tray) return;
const accounts = store.get('accounts') as Account[];
const config = store.get('serverConfig');
const contextMenu = Menu.buildFromTemplate([
{ label: `Ultimate Ban Tracker v${app.getVersion()}`, enabled: false },
{ type: 'separator' },
{
label: 'Switch Account',
submenu: accounts.length > 0 ? accounts.map(acc => ({
label: `${acc.personaName} ${acc.loginName ? `(${acc.loginName})` : ''}`,
enabled: !!acc.loginName,
click: () => handleSwitchAccount(acc.loginName)
})) : [{ label: 'No accounts found', enabled: false }]
},
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
{ type: 'separator' },
{ label: 'Show Dashboard', click: () => { if (mainWindow) mainWindow.show(); } },
{ label: 'Quit', click: () => { (app as any).isQuitting = true; app.quit(); } }
]);
tray.setContextMenu(contextMenu);
};
const setAppIcon = (themeName: string = 'steam') => {
const assetsDir = path.join(__dirname, '..', 'assets-build', 'icons');
const iconPath = path.join(assetsDir, `${themeName}.svg`);
if (!fs.existsSync(iconPath)) return;
const icon = nativeImage.createFromPath(iconPath);
// Update Tray
if (tray) {
tray.setImage(icon.resize({ width: 16, height: 16 }));
}
// Update Main Window
if (mainWindow) {
mainWindow.setIcon(icon);
}
};
// --- Steam Logic ---
const killSteam = async () => {
return new Promise<void>((resolve) => {
const command = process.platform === 'win32' ? 'taskkill /f /im steam.exe' : 'pkill -9 steam';
exec(command, () => setTimeout(resolve, 1000));
});
};
const startSteam = () => {
const command = process.platform === 'win32' ? 'start steam://open/main' : 'steam &';
exec(command);
};
const handleSwitchAccount = async (loginName: string) => {
if (!loginName) return false;
try {
await killSteam();
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.loginName === loginName);
if (process.platform === 'win32') {
const regCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`;
const rememberCommand = `reg add "HKCU\\Software\\Valve\\Steam" /v RememberPassword /t REG_DWORD /d 1 /f`;
await new Promise<void>((res, rej) => exec(`${regCommand} && ${rememberCommand}`, (e) => e ? rej(e) : res()));
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
} else if (process.platform === 'linux') {
await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam();
return true;
} catch (e) { return false; }
};
// --- Scraper Helper ---
const scrapeAccountData = async (account: Account) => {
const now = new Date();
try {
const profile = await fetchProfileData(account.steamId, account.steamLoginSecure);
const bans = await scrapeBanStatus(profile.profileUrl, account.steamLoginSecure);
account.personaName = profile.personaName; account.profileUrl = profile.profileUrl;
account.vacBanned = bans.vacBanned; account.gameBans = bans.gameBans;
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
account.lastBanCheck = now.toISOString();
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
account.avatar = profile.avatar;
const localPath = await downloadAvatar(account.steamId, profile.avatar);
if (localPath) account.localAvatar = localPath;
}
if (account.steamLoginSecure) {
try {
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
account.authError = false; account.lastScrapeTime = now.toISOString();
if (result.isActive) {
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
} else {
account.cooldownExpiresAt = undefined;
if (backend) await backend.pushCooldown(account.steamId, undefined, now.toISOString());
}
} catch (e: any) {
if (e instanceof SteamAuthError) {
account.authError = true;
} else {
console.error(`[Scraper] Temporary error for ${account.personaName}: ${e.message}`);
}
}
}
if (backend && !account._id.startsWith('shared_')) {
await backend.shareAccount(account);
}
return true;
} catch (e) {
console.error(`[Scraper] Failed to scrape ${account.personaName}:`, e);
return false;
}
};
// --- Sync Worker ---
const syncAccounts = async (isManual = false) => {
console.log(`[Sync] Phase 1: Pulling from server...`);
initBackend();
let accounts = store.get('accounts') as Account[];
let hasChanges = false;
if (backend) {
try {
const shared = await backend.getSharedAccounts();
for (const s of shared) {
const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) {
accounts.push({
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName,
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned,
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure,
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt,
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
});
hasChanges = true;
} else {
const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
// 1. SENSITIVE DATA SYNC (Credentials)
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
const isLocalAccount = !exists._id.startsWith('shared_');
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
// SMART OVERWRITE LOGIC:
// - If it's a remote shared account: Newest wins.
// - If it's a LOCAL account: Only overwrite if our local session is broken/missing.
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
if (shouldOverwriteCredentials) {
if (s.loginName) exists.loginName = s.loginName;
if (s.loginConfig) exists.loginConfig = s.loginConfig;
if (s.steamLoginSecure) {
exists.steamLoginSecure = s.steamLoginSecure;
exists.autoCheckCooldown = true;
exists.authError = false;
}
exists.sessionUpdatedAt = s.sessionUpdatedAt;
hasChanges = true;
}
// 2. Metadata Sync (Pull) - Always "Newest Wins"
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0);
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
if (sMetaDate > lMetaDate) {
exists.personaName = s.personaName;
exists.avatar = s.avatar;
exists.vacBanned = s.vacBanned;
exists.gameBans = s.gameBans;
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
exists.lastBanCheck = s.lastMetadataCheck;
hasChanges = true;
}
// Cooldown Sync (Pull)
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
if (sScrapeDate > lScrapeDate) {
exists.cooldownExpiresAt = s.cooldownExpiresAt;
exists.lastScrapeTime = s.lastScrapeTime;
hasChanges = true;
}
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) {
exists.sharedWith = s.sharedWith;
hasChanges = true;
}
}
}
} catch (e) { }
}
if (hasChanges) {
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
}
// Phase 2: Background Scrapes
const runScrapes = async () => {
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
const currentAccounts = [...store.get('accounts') as Account[]];
let scrapeChanges = false;
for (const account of currentAccounts) {
try {
const now = new Date();
if (backend && !account._id.startsWith('shared_')) await backend.shareAccount(account);
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8;
if (needsMetadata || needsCooldown || isManual) {
if (!isManual && needsCooldown) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000));
if (await scrapeAccountData(account)) scrapeChanges = true;
}
} catch (error) { }
}
if (scrapeChanges) {
store.set('accounts', currentAccounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
updateTrayMenu();
}
console.log('[Sync] Sync cycle finished.');
};
if (isManual) await runScrapes(); else runScrapes();
};
const scheduleNextSync = () => {
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
};
// --- Discovery ---
const addingAccounts = new Set<string>();
const handleLocalAccountsFound = async (localAccounts: LocalSteamAccount[]) => {
const currentAccounts = store.get('accounts') as Account[];
let hasChanges = false;
for (const local of localAccounts) {
if (addingAccounts.has(local.steamId)) continue;
const exists = currentAccounts.find(a => a.steamId === local.steamId);
if (exists) {
if (!exists.loginName && local.accountName) { exists.loginName = local.accountName; hasChanges = true; }
} else {
addingAccounts.add(local.steamId);
try {
const profile = await fetchProfileData(local.steamId);
const bans = await scrapeBanStatus(profile.profileUrl);
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
// Wait and retry snagging the config (Steam takes time to write it)
let loginConfig = undefined;
for (let i = 0; i < 3; i++) {
await new Promise(r => setTimeout(r, 2000));
loginConfig = steamClient.extractAccountConfig(local.accountName);
if (loginConfig) break;
}
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId, personaName: profile.personaName || local.accountName,
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
localAvatar: localPath, profileUrl: profile.profileUrl,
loginConfig, sessionUpdatedAt: loginConfig ? new Date().toISOString() : undefined,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
});
hasChanges = true;
} catch (e) { }
addingAccounts.delete(local.steamId);
}
}
if (hasChanges) {
store.set('accounts', currentAccounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
updateTrayMenu();
}
};
// --- Main Window ---
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true }
});
mainWindow.setMenu(null);
mainWindow.on('close', (event) => {
if (!(app as any).isQuitting) { event.preventDefault(); mainWindow?.hide(); }
return false;
});
if (isDev) mainWindow.loadURL('http://localhost:5173');
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
app.whenReady().then(() => {
protocol.handle('steam-resource', (request) => {
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
if (process.platform !== 'win32' && !rawPath.startsWith('/')) rawPath = '/' + rawPath;
const absolutePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
if (!fs.existsSync(absolutePath)) return new Response('Not Found', { status: 404 });
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
});
createWindow();
createTray();
initBackend();
setTimeout(() => syncAccounts(false), 5000);
scheduleNextSync();
steamClient.startWatching(handleLocalAccountsFound);
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin' && (app as any).isQuitting) app.quit(); });
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); else mainWindow?.show(); });
// --- IPC Handlers ---
ipcMain.handle('get-accounts', () => store.get('accounts'));
ipcMain.handle('get-server-config', () => store.get('serverConfig'));
ipcMain.handle('update-server-config', (event, config: Partial<ServerConfig>) => {
const current = store.get('serverConfig');
const updated = { ...current, ...config };
store.set('serverConfig', updated);
initBackend();
return updated;
});
ipcMain.handle('login-to-server', async () => {
initBackend();
const config = store.get('serverConfig') as ServerConfig;
if (!config.url) return false;
return new Promise<boolean>((resolve) => {
const authWindow = new BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false;
const saveServerAuth = (token: string) => {
if (captured) return; captured = true;
let serverSteamId = undefined; let isAdmin = false;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString());
serverSteamId = payload.steamId; isAdmin = !!payload.isAdmin;
} catch (e) {}
const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
initBackend();
authWindow.close();
resolve(true);
};
const filter = { urls: [`${config.url}/*`] };
authWindow.webContents.session.webRequest.onHeadersReceived(filter, (details, callback) => {
const headers = details.responseHeaders || {};
const authToken = headers['x-ubt-auth-token']?.[0] || headers['X-UBT-Auth-Token']?.[0];
if (authToken) saveServerAuth(authToken);
callback({ cancel: false });
});
authWindow.on('page-title-updated', (event, title) => {
if (title.includes('AUTH_TOKEN:')) { const token = title.split('AUTH_TOKEN:')[1]; if (token) saveServerAuth(token); }
});
authWindow.on('closed', () => resolve(false));
});
});
ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId }));
ipcMain.handle('sync-now', async () => { await syncAccounts(true); return true; });
ipcMain.handle('scrape-account', async (event, steamId: string) => {
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.steamId === steamId);
if (!account) return false;
const success = await scrapeAccountData(account);
if (success) {
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
}
return success;
});
ipcMain.handle('add-account', async (event, { identifier }) => {
try {
initBackend();
if (backend) {
const shared = await backend.getCommunityAccounts();
const existing = shared.find((s: any) => s.steamId === identifier || s.profileUrl.includes(identifier));
if (existing) {
const accounts = store.get('accounts') as Account[];
if (accounts.find(a => a.steamId === existing.steamId)) throw new Error('Account already tracked');
const newAccount: Account = {
_id: `shared_${existing.steamId}`, steamId: existing.steamId, personaName: existing.personaName,
avatar: existing.avatar, profileUrl: existing.profileUrl, vacBanned: existing.vacBanned,
gameBans: existing.gameBans, cooldownExpiresAt: existing.cooldownExpiresAt,
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith
};
store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
}
}
const profile = await fetchProfileData(identifier);
const bans = await scrapeBanStatus(profile.profileUrl);
const localAvatar = await downloadAvatar(profile.steamId, profile.avatar);
const accounts = store.get('accounts') as Account[];
const newAccount: Account = {
_id: Date.now().toString(), steamId: profile.steamId, personaName: profile.personaName,
loginName: '', avatar: profile.avatar, localAvatar: localAvatar, profileUrl: profile.profileUrl,
autoCheckCooldown: false, vacBanned: bans.vacBanned, gameBans: bans.gameBans,
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]);
updateTrayMenu();
return newAccount;
} catch (error: any) { throw error; }
});
ipcMain.handle('update-account', (event, id: string, data: Partial<Account>) => {
const accounts = store.get('accounts') as Account[];
const index = accounts.findIndex((a: Account) => a._id === id);
if (index !== -1) { accounts[index] = { ...accounts[index], ...data } as Account; store.set('accounts', accounts); updateTrayMenu(); return accounts[index]; }
return null;
});
ipcMain.handle('delete-account', (event, id: string) => {
const accounts = store.get('accounts') as Account[];
store.set('accounts', accounts.filter((a: Account) => a._id !== id));
updateTrayMenu();
return true;
});
ipcMain.handle('share-account-with-user', async (event, steamId: string, targetSteamId: string) => {
initBackend();
if (backend) {
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.steamId === steamId);
if (account) await backend.shareAccount(account);
return await backend.shareWithUser(steamId, targetSteamId);
}
throw new Error('Backend not configured');
});
ipcMain.handle('revoke-account-access', async (event, steamId: string, targetSteamId: string) => {
initBackend();
if (backend) return await backend.revokeAccess(steamId, targetSteamId);
throw new Error('Backend not configured');
});
ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => {
initBackend();
if (backend) return await backend.revokeAllAccess(steamId);
throw new Error('Backend not configured');
});
ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
// --- Admin IPC ---
ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; });
ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBackend(); if (backend) await backend.deleteUser(userId); return true; });
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
ipcMain.handle('update-app-icon', (event, themeName: string) => {
setAppIcon(themeName);
return true;
});
ipcMain.handle('switch-account', async (event, loginName: string) => {
if (!loginName) return false;
try {
// PROACTIVE SYNC: Try to snag the freshest token before we kill Steam
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.loginName === loginName);
if (account && !account._id.startsWith('shared_')) {
const freshConfig = steamClient.extractAccountConfig(loginName);
if (freshConfig) {
account.loginConfig = freshConfig;
account.sessionUpdatedAt = new Date().toISOString();
if (backend) await backend.shareAccount(account);
store.set('accounts', accounts);
}
}
await killSteam();
if (process.platform === 'win32') {
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
const commands = [
`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,
`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,
`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,
`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`
];
await new Promise<void>((res, rej) => exec(commands.join(' && '), (e) => e ? rej(e) : res()));
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
} else if (process.platform === 'linux') {
await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
}
startSteam();
return true;
} catch (e) { return false; }
});
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));
ipcMain.handle('open-steam-app-login', async () => {
await killSteam();
if (process.platform === 'win32') {
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
await new Promise<void>((res) => exec(clearReg, () => res()));
} else if (process.platform === 'linux') {
await steamClient.setAutoLoginUser("", undefined, "");
}
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
exec(command);
return true;
});
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
const loginSession = session.fromPartition(partitionId);
if (!expectedSteamId) await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
if (expectedSteamId) {
const accounts = store.get('accounts') as Account[];
const account = accounts.find(a => a.steamId === expectedSteamId);
if (account?.steamLoginSecure) {
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
for (const pair of cookiePairs) {
const [name, value] = pair.split('=');
if (name && value) {
try { await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') }); } catch (e) {}
}
}
}
}
return new Promise<boolean>((resolve) => {
const loginWindow = new BrowserWindow({
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
});
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
const checkCookie = setInterval(async () => {
try {
const cookies = await loginSession.cookies.get({ domain: 'steamcommunity.com' });
const secureCookie = cookies.find(c => c.name === 'steamLoginSecure');
if (secureCookie) {
const steamId = decodeURIComponent(secureCookie.value).split('|')[0];
if (steamId) {
if (expectedSteamId && steamId !== expectedSteamId) return;
clearInterval(checkCookie);
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const accounts = store.get('accounts') as Account[];
const accountIndex = accounts.findIndex(a => a.steamId === steamId);
if (accountIndex !== -1) {
const account = accounts[accountIndex]!;
account.steamLoginSecure = cookieString; account.autoCheckCooldown = true; account.authError = false;
account.sessionUpdatedAt = new Date().toISOString();
if (account.loginName) {
const config = steamClient.extractAccountConfig(account.loginName);
if (config) account.loginConfig = config;
}
try {
const result = await scrapeCooldown(account.steamId, cookieString);
account.lastScrapeTime = new Date().toISOString();
account.cooldownExpiresAt = result.isActive && result.expiresAt ? result.expiresAt.toISOString() : undefined;
} catch (e) { }
initBackend();
if (backend) await backend.shareAccount(account);
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
updateTrayMenu();
loginWindow.close();
resolve(true);
}
}
}
} catch (error) { }
}, 1000);
loginWindow.on('closed', () => { clearInterval(checkCookie); resolve(false); });
});
});

View File

@@ -1,40 +0,0 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
getAccounts: () => ipcRenderer.invoke('get-accounts'),
addAccount: (account: { identifier: string }) => ipcRenderer.invoke('add-account', account),
updateAccount: (id: string, data: any) => ipcRenderer.invoke('update-account', id, data),
deleteAccount: (id: string) => ipcRenderer.invoke('delete-account', id),
switchAccount: (loginName: string) => ipcRenderer.invoke('switch-account', loginName),
shareAccountWithUser: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('share-account-with-user', steamId, targetSteamId),
revokeAccountAccess: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
revokeAllAccountAccess: (steamId: string) => ipcRenderer.invoke('revoke-all-account-access', steamId),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
updateAppIcon: (theme: string) => ipcRenderer.invoke('update-app-icon', theme),
openSteamAppLogin: () => ipcRenderer.invoke('open-steam-app-login'),
openSteamLogin: (steamId: string) => ipcRenderer.invoke('open-steam-login', steamId),
// Server Config & Auth
getServerConfig: () => ipcRenderer.invoke('get-server-config'),
updateServerConfig: (config: any) => ipcRenderer.invoke('update-server-config', config),
loginToServer: () => ipcRenderer.invoke('login-to-server'),
getServerUserInfo: () => ipcRenderer.invoke('get-server-user-info'),
syncNow: () => ipcRenderer.invoke('sync-now'),
scrapeAccount: (steamId: string) => ipcRenderer.invoke('scrape-account', steamId),
getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => ipcRenderer.invoke('get-server-users'),
// Admin API
adminGetStats: () => ipcRenderer.invoke('admin-get-stats'),
adminGetUsers: () => ipcRenderer.invoke('admin-get-users'),
adminDeleteUser: (userId: string) => ipcRenderer.invoke('admin-delete-user', userId),
adminGetAccounts: () => ipcRenderer.invoke('admin-get-accounts'),
adminRemoveAccount: (steamId: string) => ipcRenderer.invoke('admin-remove-account', steamId),
onAccountsUpdated: (callback: (accounts: any[]) => void) => {
const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts);
ipcRenderer.on('accounts-updated', subscription);
return () => ipcRenderer.removeListener('accounts-updated', subscription);
},
platform: process.platform
});

View File

@@ -1,170 +0,0 @@
import axios from 'axios';
export class BackendService {
private url: string;
private token?: string;
constructor(url: string, token?: string) {
this.url = url;
this.token = token;
}
private get headers() {
return {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
};
}
public async getSharedAccounts() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/sync`, { headers: this.headers });
return response.data;
} catch (e) {
console.error('[Backend] Failed to fetch shared accounts');
return [];
}
}
public async getCommunityAccounts() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/sync/community`, { headers: this.headers });
return response.data;
} catch (e) {
console.error('[Backend] Failed to fetch community accounts');
return [];
}
}
public async getServerUsers() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/sync/users`, { headers: this.headers });
return response.data;
} catch (e) {
console.error('[Backend] Failed to fetch server users');
return [];
}
}
public async shareAccount(account: any) {
if (!this.token) return;
try {
await axios.post(`${this.url}/api/sync`, {
steamId: account.steamId,
personaName: account.personaName,
avatar: account.avatar,
profileUrl: account.profileUrl,
vacBanned: account.vacBanned,
gameBans: account.gameBans,
loginName: account.loginName,
steamLoginSecure: account.steamLoginSecure,
loginConfig: account.loginConfig,
sessionUpdatedAt: account.sessionUpdatedAt,
lastMetadataCheck: account.lastBanCheck,
lastScrapeTime: account.lastScrapeTime,
cooldownExpiresAt: account.cooldownExpiresAt
}, { headers: this.headers });
} catch (e) {
console.error('[Backend] Failed to share account');
}
}
public async pushCooldown(steamId: string, cooldownExpiresAt?: string, lastScrapeTime?: string) {
if (!this.token) return;
try {
await axios.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt,
lastScrapeTime
}, { headers: this.headers });
} catch (e) {
console.error(`[Backend] Failed to push cooldown for ${steamId}`);
}
}
public async shareWithUser(steamId: string, targetSteamId: string) {
if (!this.token) return;
try {
const response = await axios.post(`${this.url}/api/sync/${steamId}/share`, {
targetSteamId
}, { headers: this.headers });
return response.data;
} catch (e: any) {
console.error(`[Backend] Failed to share account ${steamId} with ${targetSteamId}`);
throw new Error(e.response?.data?.message || 'Failed to share account');
}
}
public async revokeAccess(steamId: string, targetSteamId: string) {
if (!this.token) return;
try {
const response = await axios.delete(`${this.url}/api/sync/${steamId}/share`, {
headers: this.headers,
data: { targetSteamId }
});
return response.data;
} catch (e: any) {
console.error(`[Backend] Failed to revoke access for ${steamId} from ${targetSteamId}`);
throw new Error(e.response?.data?.message || 'Failed to revoke access');
}
}
public async revokeAllAccess(steamId: string) {
if (!this.token) return;
try {
const response = await axios.delete(`${this.url}/api/sync/${steamId}/share/all`, {
headers: this.headers
});
return response.data;
} catch (e: any) {
console.error(`[Backend] Failed to revoke all access for ${steamId}`);
throw new Error(e.response?.data?.message || 'Failed to revoke all access');
}
}
// --- Admin API ---
public async getAdminStats() {
if (!this.token) return null;
try {
const response = await axios.get(`${this.url}/api/admin/stats`, { headers: this.headers });
return response.data;
} catch (e) { return null; }
}
public async getAdminUsers() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/users`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async deleteUser(userId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to delete user');
}
}
public async getAdminAccounts() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async forceRemoveAccount(steamId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to remove account');
}
}
}

View File

@@ -1,77 +0,0 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
export interface CooldownData {
isActive: boolean;
expiresAt?: Date;
}
// Custom error to identify session death
export class SteamAuthError extends Error {
constructor(message: string) {
super(message);
this.name = "SteamAuthError";
}
}
export const scrapeCooldown = async (steamId: string, steamLoginSecure: string): Promise<CooldownData> => {
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
try {
const response = await axios.get(url, {
headers: {
'Cookie': steamLoginSecure,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000,
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
});
// If Steam redirects us to the login page, the cookie is dead
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
}
const $ = cheerio.load(response.data);
if (!response.data.includes('Personal Game Data')) {
throw new SteamAuthError('Session invalid: Personal Game Data not accessible');
}
let expirationDate: Date | undefined = undefined;
$('table').each((_, table) => {
const headers = $(table).find('th').map((_, th) => $(th).text().trim()).get();
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration'));
if (expirationIndex !== -1) {
const rows = $(table).find('tr').not(':has(th)');
rows.each((_, row) => {
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
if (dateText && dateText !== '') {
const cleanDateText = dateText.replace(' GMT', ' UTC');
const parsed = new Date(cleanDateText);
if (!isNaN(parsed.getTime())) {
if (!expirationDate || parsed > (expirationDate as Date)) expirationDate = parsed;
}
}
});
}
});
if (expirationDate && (expirationDate as Date).getTime() > Date.now()) {
return { isActive: true, expiresAt: expirationDate };
}
const content = $('#personal_game_data_content').text();
if (content.includes('Competitive Cooldown') || content.includes('Your account is currently')) {
return { isActive: true };
}
return { isActive: false };
} catch (error: any) {
if (error instanceof SteamAuthError) throw error;
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message);
throw error; // Generic errors don't trigger re-auth
}
};

View File

@@ -1,249 +0,0 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { parse, stringify } from 'simple-vdf';
import chokidar from 'chokidar';
export interface LocalSteamAccount {
steamId: string;
accountName: string;
personaName: string;
timestamp: number;
}
class SteamClientService {
private steamPath: string | null = null;
private onAccountsChanged: ((accounts: LocalSteamAccount[]) => void) | null = null;
constructor() {
this.detectSteamPath();
}
private detectSteamPath() {
const platform = os.platform();
const home = os.homedir();
if (platform === 'win32') {
const possiblePaths = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
path.join(process.env.APPDATA || '', 'Steam'),
];
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
} else if (platform === 'linux') {
const possiblePaths = [
path.join(home, '.steam/steam'),
path.join(home, '.local/share/Steam'),
path.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak
path.join(home, 'snap/steam/common/.steam/steam'), // Snap
];
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
}
if (this.steamPath) {
console.log(`[SteamClient] Detected Steam path: ${this.steamPath}`);
}
}
public getLoginUsersPath(): string | null {
if (!this.steamPath) return null;
return path.join(this.steamPath, 'config', 'loginusers.vdf');
}
public getConfigVdfPath(): string | null {
if (!this.steamPath) return null;
return path.join(this.steamPath, 'config', 'config.vdf');
}
private safeWriteVdf(filePath: string, data: any) {
const tempPath = `${filePath}.tmp_${Date.now()}`;
const dir = path.dirname(filePath);
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const vdfContent = stringify(data);
fs.writeFileSync(tempPath, vdfContent, 'utf-8');
fs.renameSync(tempPath, filePath);
} catch (e: any) {
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
throw e;
}
}
public startWatching(callback: (accounts: LocalSteamAccount[]) => void) {
this.onAccountsChanged = callback;
const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath && fs.existsSync(loginUsersPath)) {
this.readLocalAccounts();
chokidar.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => {
this.readLocalAccounts();
});
}
}
private readLocalAccounts() {
const filePath = this.getLoginUsersPath();
if (!filePath || !fs.existsSync(filePath)) return;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.trim()) return;
const data = parse(content) as any;
if (!data || !data.users) return;
const accounts: LocalSteamAccount[] = [];
for (const [steamId64, userData] of Object.entries(data.users)) {
const user = userData as any;
if (!user || !user.AccountName) continue;
accounts.push({
steamId: steamId64, accountName: user.AccountName,
personaName: user.PersonaName || user.AccountName,
timestamp: parseInt(user.Timestamp) || 0
});
}
if (this.onAccountsChanged) this.onAccountsChanged(accounts);
} catch (error) { }
}
public extractAccountConfig(accountName: string): any | null {
const configPath = this.getConfigVdfPath();
if (!configPath || !fs.existsSync(configPath)) return null;
try {
const content = fs.readFileSync(configPath, 'utf-8');
const data = parse(content) as any;
const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts;
return (accounts && accounts[accountName]) ? accounts[accountName] : null;
} catch (e) { return null; }
}
public injectAccountConfig(accountName: string, accountData: any) {
const configPath = this.getConfigVdfPath();
if (!configPath) return;
let data: any = { InstallConfigStore: { Software: { Valve: { Steam: { Accounts: {} } } } } };
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = parse(content) as any;
if (parsed && typeof parsed === 'object') data = parsed;
} catch (e) { }
}
const ensurePath = (obj: any, keys: string[]) => {
let curr = obj;
for (const key of keys) {
if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {};
curr = curr[key];
}
return curr;
};
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']);
// FAILPROOF: Force crucial flags that Steam uses to decide session validity
steamAccounts[accountName] = {
...accountData,
RememberPassword: "1",
AllowAutoLogin: "1",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
try {
this.safeWriteVdf(configPath, data);
console.log(`[SteamClient] Safely injected session for ${accountName}`);
} catch (e) { }
}
public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise<boolean> {
const platform = os.platform();
const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath) {
let data: any = { users: {} };
if (fs.existsSync(loginUsersPath)) {
try {
const content = fs.readFileSync(loginUsersPath, 'utf-8');
const parsed = parse(content) as any;
if (parsed && parsed.users) data = parsed;
} catch (e) { }
}
if (!data.users) data.users = {};
let found = false;
for (const [id, user] of Object.entries(data.users)) {
const u = user as any;
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) {
u.mostrecent = "1";
u.RememberPassword = "1";
u.AllowAutoLogin = "1";
u.WantsOfflineMode = "0";
u.SkipOfflineModeWarning = "1";
u.WasNonInteractive = "0";
found = true;
} else {
u.mostrecent = "0";
}
}
if (!found && steamId && accountName) {
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`);
data.users[steamId] = {
AccountName: accountName,
PersonaName: accountName,
RememberPassword: "1",
mostrecent: "1",
AllowAutoLogin: "1",
WantsOfflineMode: "0",
SkipOfflineModeWarning: "1",
WasNonInteractive: "0",
Timestamp: Math.floor(Date.now() / 1000).toString()
};
}
try {
this.safeWriteVdf(loginUsersPath, data);
} catch (e) { }
}
// Injection of the actual authentication blob
if (accountConfig && accountName) {
this.injectAccountConfig(accountName, accountConfig);
}
// --- Linux Registry / Registry.vdf Hardening ---
if (platform === 'linux') {
const regLocations = [
path.join(os.homedir(), '.steam', 'registry.vdf'),
path.join(os.homedir(), '.steam', 'steam', 'registry.vdf')
];
for (const regPath of regLocations) {
if (!fs.existsSync(path.dirname(regPath))) continue;
let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
if (fs.existsSync(regPath)) {
try {
const content = fs.readFileSync(regPath, 'utf-8');
const parsed = parse(content) as any;
if (parsed && typeof parsed === 'object') regData = parsed;
} catch (e) { }
}
const ensurePath = (obj: any, keys: string[]) => {
let curr = obj;
for (const key of keys) { if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {}; curr = curr[key]; }
return curr;
};
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']);
steamKey.AutoLoginUser = accountName;
steamKey.RememberPassword = "1";
steamKey.AlreadyLoggedIn = "1";
steamKey.WantsOfflineMode = "0";
try { this.safeWriteVdf(regPath, regData); } catch (e) { }
}
}
return true;
}
}
export const steamClient = new SteamClientService();

View File

@@ -1,88 +0,0 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
export interface SteamWebProfile {
steamId: string;
personaName: string;
avatar: string;
profileUrl: string;
}
const AXIOS_CONFIG = {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
};
export const fetchProfileData = async (identifier: string, steamLoginSecure?: string): Promise<SteamWebProfile> => {
let url = '';
// Clean identifier
const cleanId = identifier.replace(/https?:\/\/steamcommunity\.com\/(profiles|id)\//, '').replace(/\/$/, '');
if (cleanId.match(/^\d+$/)) {
url = `https://steamcommunity.com/profiles/${cleanId}?xml=1`;
} else {
url = `https://steamcommunity.com/id/${cleanId}?xml=1`;
}
const headers = { ...AXIOS_CONFIG.headers } as any;
if (steamLoginSecure) {
headers['Cookie'] = steamLoginSecure;
}
try {
const response = await axios.get(url, { ...AXIOS_CONFIG, headers });
const $ = cheerio.load(response.data, { xmlMode: true });
const steamId = $('steamID64').first().text().trim();
const personaName = $('steamID').first().text().trim();
const avatarRaw = $('avatarFull').first().text().trim();
// Robustly extract the first URL if concatenated
let avatar = avatarRaw;
const urls = avatarRaw.match(/https?:\/\/[^\s"'<>]+/g);
if (urls && urls.length > 0) {
avatar = urls[0]!;
}
// Ensure https
if (avatar && avatar.startsWith('http:')) {
avatar = avatar.replace('http:', 'https:');
}
const profileUrl = steamId
? `https://steamcommunity.com/profiles/${steamId}`
: (cleanId.match(/^\d+$/) ? `https://steamcommunity.com/profiles/${cleanId}` : `https://steamcommunity.com/id/${cleanId}`);
return {
steamId: steamId || cleanId,
personaName: personaName || 'Unknown',
avatar: avatar || 'https://avatars.akamai.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
profileUrl
};
} catch (error: any) {
throw new Error(`Failed to fetch profile: ${error.message}`);
}
};
export const scrapeBanStatus = async (profileUrl: string, steamLoginSecure?: string): Promise<{ vacBanned: boolean, gameBans: number }> => {
try {
const headers = { ...AXIOS_CONFIG.headers } as any;
if (steamLoginSecure) {
headers['Cookie'] = steamLoginSecure;
}
const response = await axios.get(profileUrl, { ...AXIOS_CONFIG, headers });
const $ = cheerio.load(response.data);
const banText = $('.profile_ban').text().toLowerCase();
const vacBanned = banText.includes('vac ban');
const gameBansMatch = banText.match(/(\d+)\s+game\s+ban/);
const gameBans = gameBansMatch ? parseInt(gameBansMatch[1]!) : 0;
return { vacBanned, gameBans };
} catch (error) {
return { vacBanned: false, gameBans: 0 };
}
};

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"lib": ["ESNext"],
"outDir": "../dist-electron",
"rootDir": ".",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["main.ts", "preload.ts", "services/**/*", "types/**/*"]
}

View File

@@ -1,4 +0,0 @@
declare module 'simple-vdf' {
export function parse(content: string): any;
export function stringify(data: any): string;
}

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ultimate Ban Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +0,0 @@
{
"name": "ultimate-ban-tracker-desktop",
"description": "Professional Steam Account Manager & Ban Tracker",
"version": "1.3.3",
"author": "Nils Pukropp <nils@narl.io>",
"homepage": "https://narl.io",
"license": "SEE LICENSE IN LICENSE",
"repository": "nvrl/ultimate-ban-tracker",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"electron:dev": "concurrently -k \"cross-env BROWSER=none npm run dev\" \"npx tsc -p electron/tsconfig.json -w\" \"wait-on http://localhost:5173 && wait-on dist-electron/main.js && electron .\"",
"electron:build": "vite build && npx tsc -p electron/tsconfig.json && npm prune --production && npm install && electron-builder --publish never"
},
"build": {
"appId": "io.narl.ultimatebantracker",
"productName": "Ultimate Ban Tracker",
"copyright": "Copyright © 2026 Nils Pukropp",
"directories": {
"output": "release"
},
"publish": {
"provider": "generic",
"url": "https://ultimate-ban-tracker.narl.io"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"assets-build/**/*"
],
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Game",
"icon": "assets-build/icon.png"
},
"win": {
"target": "nsis",
"icon": "assets-build/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"shortcutName": "Ultimate Ban Tracker"
}
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.90.21",
"ajv-formats": "^3.0.1",
"axios": "^1.13.5",
"call-bind-apply-helpers": "^1.0.2",
"cheerio": "^1.2.0",
"chokidar": "^5.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"dotenv": "^16.4.7",
"dunder-proto": "^1.0.1",
"electron-is-dev": "^3.0.1",
"electron-store": "^11.0.2",
"framer-motion": "^12.34.3",
"get-proto": "^1.0.1",
"lucide-react": "^0.575.0",
"node-cron": "^4.2.1",
"ps-node": "^0.1.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"simple-vdf": "^1.1.1",
"vdf": "^0.0.2",
"wait-on": "^9.0.4"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/ps-node": "^0.1.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^40.6.0",
"electron-builder": "^26.8.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"rimraf": "^6.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
},
"overrides": {
"minimatch": "^10.0.0",
"glob": "^13.0.0",
"rimraf": "^6.0.1",
"tar": "^7.4.3",
"whatwg-encoding": "^3.1.1"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,13 +0,0 @@
import React from 'react';
import { AccountsProvider } from './hooks/useAccounts';
import Dashboard from './pages/Dashboard';
const App: React.FC = () => {
return (
<AccountsProvider>
<Dashboard />
</AccountsProvider>
);
};
export default App;

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
const NebulaBanner: React.FC = () => {
return (
<Box
sx={{
height: 4,
width: '100%',
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark || theme.palette.primary.main} 50%, ${theme.palette.primary.main} 100%)`,
boxShadow: (theme) => `0 0 10px ${theme.palette.primary.main}`,
zIndex: 2000
}}
/>
);
};
export default NebulaBanner;

View File

@@ -1,198 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Card, CardContent, Typography, Box, Avatar, IconButton, Tooltip,
Chip, Dialog, DialogTitle, DialogContent,
TextField, DialogActions, Button
} from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt';
import TimerIcon from '@mui/icons-material/Timer';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DeleteIcon from '@mui/icons-material/Delete';
interface SteamCardProps {
account: {
_id: string;
steamId: string;
personaName: string;
loginName?: string;
steamLoginSecure?: string;
autoCheckCooldown: boolean;
accountType: 'owned' | 'watched';
avatar: string;
profileUrl?: string;
status?: string;
vacBanned: boolean;
gameBans: number;
lastBanCheck: string;
cooldownExpiresAt?: string;
};
onDelete: (id: string) => void;
onUpdate: (id: string, data: any) => void;
onFastLogin: () => void;
}
const SteamCard: React.FC<SteamCardProps> = ({ account, onDelete, onUpdate, onFastLogin }) => {
const [timeLeft, setTimeLeft] = useState<string | null>(null);
const [isLoginNameDialogOpen, setIsLoginNameDialogOpen] = useState(false);
const [tempLoginName, setTempLoginName] = useState(account.loginName || '');
const isOwned = account.accountType === 'owned';
const isPermanentlyBanned = account.vacBanned || account.gameBans > 0 || account.status === 'banned';
const cooldownDate = account.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now();
useEffect(() => {
if (!isCooldownActive || !cooldownDate) {
setTimeLeft(null);
return;
}
const timer = setInterval(() => {
const now = new Date();
const diff = cooldownDate.getTime() - now.getTime();
if (diff <= 0) {
setTimeLeft(null);
clearInterval(timer);
return;
}
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${hours}h ${mins}m ${secs}s`);
}, 1000);
return () => clearInterval(timer);
}, [account.cooldownExpiresAt, isCooldownActive, cooldownDate]);
const getStatusColor = () => {
if (isPermanentlyBanned) return '#af3212';
if (isCooldownActive) return '#ff9800';
return '#5c7e10';
};
const getStatusText = () => {
if (account.vacBanned) return 'VAC BANNED';
if (account.gameBans > 0) return `${account.gameBans} GAME BAN${account.gameBans > 1 ? 'S' : ''}`;
if (isPermanentlyBanned) return 'BANNED';
if (isCooldownActive) return 'COOLDOWN';
return 'AVAILABLE';
};
const handleFastLoginClick = () => {
if (!account.loginName) {
setIsLoginNameDialogOpen(true);
return;
}
onFastLogin();
};
return (
<Card sx={{
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: `0 0 20px rgba(102, 192, 244, 0.2)`
},
borderTop: `4px solid ${getStatusColor()}`
}}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar
src={account.avatar}
variant="square"
sx={{
width: 64,
height: 64,
border: `2px solid ${getStatusColor()}`,
boxShadow: isPermanentlyBanned ? '0 0 10px rgba(175, 50, 18, 0.4)' : 'none'
}}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="h6" noWrap sx={{ color: '#ffffff', mb: 0.5, maxWidth: '140px' }}>
{account.personaName || 'Unknown'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={getStatusText()}
size="small"
sx={{
backgroundColor: getStatusColor(),
color: '#ffffff',
fontWeight: 'bold',
fontSize: '0.65rem',
height: 20
}}
/>
{timeLeft && (
<Typography variant="caption" sx={{ color: '#ff9800', fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
<TimerIcon sx={{ fontSize: 12, mr: 0.5 }} /> {timeLeft}
</Typography>
)}
</Box>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{isOwned && (
<Button
variant="contained"
size="small"
onClick={handleFastLoginClick}
sx={{
minWidth: 'unset',
px: 1,
py: 0.2,
fontSize: '0.7rem',
background: account.loginName ? 'linear-gradient(to bottom, #8ed629 5%, #5c7e10 95%)' : 'rgba(255,255,255,0.1)'
}}
>
<BoltIcon sx={{ fontSize: 14, mr: 0.5 }} /> LOGIN
</Button>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="View Profile">
<IconButton size="small" component="a" href={account.profileUrl || '#'} target="_blank" rel="noopener noreferrer">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Remove Tracker">
<IconButton size="small" color="error" onClick={() => onDelete(account._id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Dialog open={isLoginNameDialogOpen} onClose={() => setIsLoginNameDialogOpen(false)}>
<DialogTitle sx={{ backgroundColor: '#1b2838', color: '#ffffff' }}>Steam Login Name</DialogTitle>
<DialogContent sx={{ backgroundColor: '#1b2838', pt: 2 }}>
<TextField
fullWidth
autoFocus
label="Steam Username"
value={tempLoginName}
onChange={(e) => setTempLoginName(e.target.value)}
/>
</DialogContent>
<DialogActions sx={{ backgroundColor: '#1b2838', p: 2 }}>
<Button onClick={() => setIsLoginNameDialogOpen(false)} color="inherit">Cancel</Button>
<Button onClick={() => { onUpdate(account._id, { loginName: tempLoginName }); setIsLoginNameDialogOpen(false); }} variant="contained" color="primary">Save</Button>
</DialogActions>
</Dialog>
</CardContent>
</Card>
);
};
export default SteamCard;

View File

@@ -1,217 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
export interface Account {
_id: string;
steamId: string;
personaName: string;
loginName?: string;
steamLoginSecure?: string;
loginConfig?: any;
sessionUpdatedAt?: string;
autoCheckCooldown: boolean;
avatar: string;
localAvatar?: string;
profileUrl: string;
status: string;
vacBanned: boolean;
gameBans: number;
lastBanCheck: string;
lastScrapeTime?: string;
cooldownExpiresAt?: string;
authError?: boolean;
notes?: string;
}
export interface ServerConfig {
url: string;
token?: string;
serverSteamId?: string;
enabled: boolean;
isAdmin?: boolean;
}
interface AccountsContextType {
accounts: Account[];
serverConfig: ServerConfig | null;
isLoading: boolean;
isSyncing: boolean;
addAccount: (data: { identifier: string }) => Promise<void>;
updateAccount: (id: string, data: Partial<Account>) => Promise<void>;
deleteAccount: (id: string) => Promise<void>;
switchAccount: (loginName: string) => Promise<void>;
openSteamAppLogin: () => Promise<void>;
openSteamLogin: (steamId: string) => Promise<void>;
shareAccountWithUser: (steamId: string, targetSteamId: string) => Promise<any>;
revokeAccountAccess: (steamId: string, targetSteamId: string) => Promise<any>;
revokeAllAccountAccess: (steamId: string) => Promise<any>;
// Server Methods
updateServerConfig: (config: Partial<ServerConfig>) => Promise<void>;
loginToServer: () => Promise<void>;
syncNow: () => Promise<void>;
scrapeAccount: (steamId: string) => Promise<boolean>;
getCommunityAccounts: () => Promise<any[]>;
getServerUsers: () => Promise<any[]>;
refreshAccounts: (showLoading?: boolean) => Promise<void>;
// Admin Methods
adminGetStats: () => Promise<any>;
adminGetUsers: () => Promise<any[]>;
adminDeleteUser: (userId: string) => Promise<void>;
adminGetAccounts: () => Promise<any[]>;
adminRemoveAccount: (steamId: string) => Promise<void>;
}
const AccountsContext = createContext<AccountsContextType | undefined>(undefined);
export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [accounts, setAccounts] = useState<Account[]>([]);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const refreshAccounts = async (showLoading = false) => {
if (showLoading) setIsLoading(true);
try {
const api = (window as any).electronAPI;
if (!api) {
console.warn("[useAccounts] electronAPI not found in window");
return;
}
console.log("[useAccounts] Fetching data from main process...");
const accData = await api.getAccounts();
const configData = await api.getServerConfig();
setAccounts(Array.isArray(accData) ? accData : []);
setServerConfig(configData || null);
} catch (error) {
console.error("[useAccounts] Error loading accounts:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
refreshAccounts(true);
const api = (window as any).electronAPI;
if (api?.onAccountsUpdated) {
const cleanup = api.onAccountsUpdated((updatedAccounts: Account[]) => {
setAccounts(Array.isArray(updatedAccounts) ? updatedAccounts : []);
});
return typeof cleanup === 'function' ? cleanup : undefined;
}
}, []);
const syncNow = async () => {
setIsSyncing(true);
try {
await (window as any).electronAPI.syncNow();
await refreshAccounts();
} catch (e) {
console.error("[useAccounts] Sync failed", e);
} finally {
setIsSyncing(false);
}
};
const scrapeAccount = async (steamId: string) => {
const success = await (window as any).electronAPI.scrapeAccount(steamId);
if (success) await syncNow();
return success;
};
const addAccount = async (data: { identifier: string }) => {
await (window as any).electronAPI.addAccount(data);
await refreshAccounts();
await syncNow();
};
const updateAccount = async (id: string, data: Partial<Account>) => {
await (window as any).electronAPI.updateAccount(id, data);
await refreshAccounts();
};
const deleteAccount = async (id: string) => {
if (!window.confirm("Are you sure you want to remove this account?")) return;
await (window as any).electronAPI.deleteAccount(id);
await refreshAccounts();
};
const switchAccount = async (loginName: string) => {
if (!loginName) return;
await (window as any).electronAPI.switchAccount(loginName);
};
const openSteamAppLogin = async () => {
await (window as any).electronAPI.openSteamAppLogin();
};
const openSteamLogin = async (steamId: string) => {
await (window as any).electronAPI.openSteamLogin(steamId);
await syncNow();
};
const shareAccountWithUser = async (steamId: string, targetSteamId: string) => {
const res = await (window as any).electronAPI.shareAccountWithUser(steamId, targetSteamId);
await syncNow();
return res;
};
const revokeAccountAccess = async (steamId: string, targetSteamId: string) => {
const res = await (window as any).electronAPI.revokeAccountAccess(steamId, targetSteamId);
await syncNow();
return res;
};
const revokeAllAccountAccess = async (steamId: string) => {
const res = await (window as any).electronAPI.revokeAllAccountAccess(steamId);
await syncNow();
return res;
};
const updateServerConfig = async (config: Partial<ServerConfig>) => {
const updated = await (window as any).electronAPI.updateServerConfig(config);
setServerConfig(updated);
};
const loginToServer = async () => {
await (window as any).electronAPI.loginToServer();
await refreshAccounts();
await syncNow();
};
const getCommunityAccounts = async () => {
return await (window as any).electronAPI.getCommunityAccounts();
};
const getServerUsers = async () => {
return await (window as any).electronAPI.getServerUsers();
};
// --- Admin Methods ---
const adminGetStats = async () => (window as any).electronAPI.adminGetStats();
const adminGetUsers = async () => (window as any).electronAPI.adminGetUsers();
const adminDeleteUser = async (userId: string) => (window as any).electronAPI.adminDeleteUser(userId);
const adminGetAccounts = async () => (window as any).electronAPI.adminGetAccounts();
const adminRemoveAccount = async (steamId: string) => (window as any).electronAPI.adminRemoveAccount(steamId);
return (
<AccountsContext.Provider value={{
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer,
getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts,
scrapeAccount, adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
}}>
{children}
</AccountsContext.Provider>
);
};
export const useAccounts = () => {
const context = useContext(AccountsContext);
if (context === undefined) {
throw new Error('useAccounts must be used within an AccountsProvider');
}
return context;
};

View File

@@ -1,77 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
interface User {
_id: string;
steamId: string;
personaName: string;
avatar: string;
role: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (token: string) => void;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [isLoading, setIsLoading] = useState(true);
const login = (newToken: string) => {
localStorage.setItem('token', newToken);
setIsLoading(true); // Start loading immediately
setToken(newToken);
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
useEffect(() => {
const fetchUser = async () => {
if (!token) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const response = await axios.get(`${apiUrl}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
});
setUser(response.data.user);
} catch (error) {
console.error('Failed to fetch user:', error);
logout();
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [token]);
return (
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -1,22 +0,0 @@
body {
margin: 0;
padding: 0;
background-color: #171a21;
color: white;
font-family: "Motiva Sans", "Roboto", sans-serif;
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Remove default Vite styles that might interfere */
:root {
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
}

View File

@@ -1,65 +0,0 @@
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import ReactDOM from 'react-dom/client';
import { Box, Typography, Button } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppThemeProvider } from './theme/ThemeContext';
import App from './App';
console.log("[Renderer] Initializing React App...");
window.addEventListener('error', (event) => {
console.error("[Global Error]", event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error("[Unhandled Rejection]", event.reason);
});
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean, error: Error | null }> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[Renderer] CRASH DETECTED:", error);
console.error("[Renderer] Error Info:", errorInfo);
}
render() {
if (this.state.hasError) {
return (
<Box sx={{ p: 4, textAlign: 'center', backgroundColor: '#171a21', color: 'white', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Typography variant="h4" color="error" gutterBottom>Application Error</Typography>
<Typography variant="body1" sx={{ mb: 2, opacity: 0.8, maxWidth: '600px' }}>{this.state.error?.stack || this.state.error?.message}</Typography>
<Button variant="contained" color="primary" onClick={() => window.location.reload()}>Retry App</Button>
</Box>
);
}
return this.props.children;
}
}
const queryClient = new QueryClient();
const rootElement = document.getElementById('root');
if (!rootElement) {
console.error("[Renderer] Failed to find #root element!");
} else {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AppThemeProvider>
<App />
</AppThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</React.StrictMode>
);
console.log("[Renderer] Render initiated.");
}

View File

@@ -1,597 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box, Container, Typography, Button, TextField,
InputAdornment, IconButton, AppBar, Toolbar, Avatar,
Tooltip, Dialog, DialogTitle, DialogContent,
DialogActions, CircularProgress, Paper, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction,
Select, MenuItem, FormControl, InputLabel, Tabs, Tab
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import SteamIcon from '@mui/icons-material/SportsEsports';
import DeleteIcon from '@mui/icons-material/Delete';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import SyncIcon from '@mui/icons-material/Sync';
import BoltIcon from '@mui/icons-material/Bolt';
import TimerIcon from '@mui/icons-material/Timer';
import LockResetIcon from '@mui/icons-material/LockReset';
import SettingsIcon from '@mui/icons-material/Settings';
import ShareIcon from '@mui/icons-material/Share';
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import ShieldIcon from '@mui/icons-material/Shield';
import GppBadIcon from '@mui/icons-material/GppBad';
import PeopleIcon from '@mui/icons-material/People';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import StorageIcon from '@mui/icons-material/Storage';
import GroupIcon from '@mui/icons-material/Group';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import { useAccounts, type Account } from '../hooks/useAccounts';
import { useAppTheme } from '../theme/ThemeContext';
import type { ThemeType } from '../theme/SteamTheme';
import NebulaBanner from '../components/NebulaBanner';
const AdminPanel: React.FC<{ open: boolean, onClose: () => void }> = ({ open, onClose }) => {
const { adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount } = useAccounts();
const [tab, setTab] = useState(0);
const [stats, setStats] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
if (tab === 0) setStats(await adminGetStats());
if (tab === 1) setUsers(await adminGetUsers());
if (tab === 2) setAccounts(await adminGetAccounts());
} catch (e) {}
setLoading(false);
};
useEffect(() => { if (open) loadData(); }, [open, tab]);
const handleDeleteUser = async (id: string) => {
if (window.confirm("Wipe this user and all their accounts?")) {
await adminDeleteUser(id);
loadData();
}
};
const handleForceRemove = async (steamId: string) => {
if (window.confirm("Force remove this account from server?")) {
await adminRemoveAccount(steamId);
loadData();
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ bgcolor: 'background.paper', color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
<AdminPanelSettingsIcon color="primary" /> Server Administration
</DialogTitle>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Tab icon={<StorageIcon />} label="Overview" />
<Tab icon={<GroupIcon />} label="Users" />
<Tab icon={<AccountTreeIcon />} label="Global Accounts" />
</Tabs>
<DialogContent sx={{ bgcolor: 'background.paper', minHeight: 400, pt: 2 }}>
{loading ? <Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}><CircularProgress /></Box> : (
<>
{tab === 0 && stats && (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 2 }}>
{[
{ label: 'Total Users', value: stats.users },
{ label: 'Total Accounts', value: stats.accounts },
{ label: 'Active Cooldowns', value: stats.activeCooldowns }
].map((s) => (
<Paper key={s.label} sx={{ p: 3, textAlign: 'center', bgcolor: 'rgba(0,0,0,0.1)' }}>
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>{s.value}</Typography>
<Typography variant="caption" color="textSecondary">{s.label}</Typography>
</Paper>
))}
</Box>
)}
{tab === 1 && (
<List>
{users.map(u => (
<ListItem key={u._id} divider sx={{ borderColor: 'divider' }}>
<Avatar src={u.avatar} sx={{ mr: 2 }} />
<ListItemText primary={u.personaName} secondary={u.steamId} primaryTypographyProps={{ color: 'text.primary' }} />
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleDeleteUser(u._id)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{tab === 2 && (
<List>
{accounts.map(a => (
<ListItem key={a.steamId} divider sx={{ borderColor: 'divider' }}>
<Avatar src={a.avatar} variant="square" sx={{ mr: 2 }} />
<ListItemText
primary={a.personaName}
secondary={`Owned by: ${a.addedBy?.personaName || 'Unknown'} (${a.steamId})`}
primaryTypographyProps={{ color: 'text.primary' }}
/>
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleForceRemove(a.steamId)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ bgcolor: 'background.paper', p: 2 }}>
<Button onClick={onClose} variant="contained" color="inherit">Close Panel</Button>
</DialogActions>
</Dialog>
);
};
const Dashboard: React.FC = () => {
const { currentTheme, setTheme } = useAppTheme();
const {
accounts, isLoading, isSyncing, serverConfig, deleteAccount,
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer, syncNow
} = useAccounts();
const [searchTerm, setSearchTerm] = useState('');
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAdminPanelOpen, setIsAdminPanelOpen] = useState(false);
const [serverUrl, setServerUrl] = useState('');
useEffect(() => {
if (serverConfig?.url) setServerUrl(serverConfig.url);
}, [serverConfig?.url]);
const saveSettings = async () => {
await updateServerConfig({ url: serverUrl });
alert("Server URL updated!");
};
const safeAccounts = Array.isArray(accounts) ? accounts : [];
const filteredAccounts = safeAccounts.filter((acc) => {
if (!acc) return false;
const name = acc.personaName || 'Unknown';
const id = acc.steamId || '';
return name.toLowerCase().includes(searchTerm.toLowerCase()) || id.includes(searchTerm);
});
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: 'background.default' }}>
<NebulaBanner />
<AppBar position="sticky" sx={{ WebkitAppRegion: 'drag', background: 'background.default', boxShadow: 'none', borderBottom: '1px solid', borderColor: 'divider' } as any}>
<Toolbar variant="dense">
<SteamIcon sx={{ mr: 2, color: 'primary.main' }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 'bold', fontSize: '1rem', letterSpacing: '1px' }}>
ULTIMATE BAN TRACKER
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}>
{/* Admin Button - Only visible if isAdmin is true */}
{serverConfig?.isAdmin && (
<Tooltip title="Open Admin Panel">
<IconButton color="primary" onClick={() => setIsAdminPanelOpen(true)}>
<AdminPanelSettingsIcon />
</IconButton>
</Tooltip>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
{isSyncing ? (
<CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} />
) : (
<IconButton size="small" onClick={() => syncNow()} disabled={!serverConfig?.enabled} sx={{ color: 'primary.main' }}>
<SyncIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
<TextField
size="small"
placeholder="Search accounts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 1,
width: 200,
'& .MuiOutlinedInput-root': { '& fieldset': { border: 'none' }, height: 32 }
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
}}
/>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => openSteamAppLogin()}
sx={{ height: 32 }}
>
Add
</Button>
<IconButton color="inherit" onClick={() => setIsSettingsOpen(true)}>
<SettingsIcon />
</IconButton>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4, flexGrow: 1, overflowY: 'auto' }}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}><CircularProgress color="primary" /></Box>
) : (
<TableContainer component={Paper} sx={{ background: 'background.paper', backdropFilter: 'blur(10px)', borderRadius: 0, border: '1px solid', borderColor: 'divider' }}>
<Table size="small">
<TableHead>
<TableRow sx={{ background: 'rgba(0,0,0,0.1)' }}>
<TableCell sx={{ color: 'text.secondary', fontWeight: 'bold', width: 60 }}>AVATAR</TableCell>
<TableCell sx={{ color: 'text.secondary', fontWeight: 'bold' }}>ACCOUNT</TableCell>
<TableCell sx={{ color: 'text.secondary', fontWeight: 'bold' }}>BAN STATUS</TableCell>
<TableCell sx={{ color: 'text.secondary', fontWeight: 'bold' }}>COOLDOWN</TableCell>
<TableCell sx={{ color: 'text.secondary', fontWeight: 'bold', textAlign: 'right' }}>ACTIONS</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredAccounts.map((account) => (
<AccountRow
key={account._id}
account={account}
onDelete={deleteAccount}
onSwitch={switchAccount}
onAuth={() => openSteamLogin(account.steamId)}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
{!isLoading && filteredAccounts.length === 0 && (
<Box sx={{ width: '100%', mt: 10, textAlign: 'center' }}>
<Typography variant="h6" color="textSecondary">
No accounts tracked. Click "Add" to get started!
</Typography>
</Box>
)}
</Container>
{/* Settings Dialog */}
<Dialog open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Settings & Customization</DialogTitle>
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ color: 'primary.main', mt: 1 }}>THEME SELECTION</Typography>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel sx={{ color: 'text.secondary' }}>Active Theme</InputLabel>
<Select
value={currentTheme || 'steam'}
label="Active Theme"
onChange={(e) => setTheme(e.target.value as ThemeType)}
sx={{ bgcolor: 'rgba(0,0,0,0.1)', color: 'text.primary' }}
>
<MenuItem value="steam">Steam Classic</MenuItem>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="nord">Nord Arctic</MenuItem>
<MenuItem value="tokyo">Tokyo Night</MenuItem>
</Select>
</FormControl>
<Divider sx={{ my: 2, borderColor: 'divider' }} />
<Typography variant="subtitle2" gutterBottom sx={{ color: 'primary.main' }}>BACKEND CONFIGURATION</Typography>
<TextField
fullWidth
label="Server URL"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://ultimate-ban-tracker.narl.io"
margin="dense"
sx={{ mb: 2 }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Button variant="contained" size="small" onClick={saveSettings} sx={{ height: 30 }}>Apply</Button>
</InputAdornment>
),
}}
/>
<Divider sx={{ my: 2, borderColor: 'divider' }} />
<Typography variant="subtitle2" gutterBottom sx={{ color: 'primary.main' }}>COMMUNITY AUTHENTICATION</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', p: 2, bgcolor: 'rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{serverConfig?.token ? "Connected to Server" : "Not Authenticated"}
</Typography>
<Typography variant="caption" color="textSecondary">
{serverConfig?.token ? "Your accounts can now be shared with others." : "Login to share and sync with your community."}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{serverConfig?.token && (
<Button
variant="outlined"
color="error"
onClick={() => updateServerConfig({ token: undefined, enabled: false })}
>
Logout
</Button>
)}
<Button
variant="outlined"
color="primary"
onClick={() => loginToServer()}
disabled={!serverUrl}
>
{serverConfig?.token ? "Re-Login" : "Login with Steam"}
</Button>
</Box>
</Box>
<FormControlLabel
control={
<Switch
checked={serverConfig?.enabled || false}
onChange={(e) => updateServerConfig({ enabled: e.target.checked })}
disabled={!serverConfig?.token}
/>
}
label="Enable Community Sync"
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}>
<Button onClick={() => setIsSettingsOpen(false)} color="inherit" variant="contained">Done</Button>
</DialogActions>
</Dialog>
{/* Admin Panel */}
<AdminPanel open={isAdminPanelOpen} onClose={() => setIsAdminPanelOpen(false)} />
</Box>
);
};
// --- Sub-Component: AccountRow ---
const AccountRow: React.FC<{
account: Account,
onDelete: (id: string) => void,
onSwitch: (login: string) => void,
onAuth: () => void
}> = ({ account, onDelete, onSwitch, onAuth }) => {
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig, scrapeAccount } = useAccounts();
const [timeLeft, setTimeLeft] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [targetUserId, setTargetUserId] = useState('');
const [isSharing, setIsSharing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [serverUsers, setServerUsers] = useState<any[]>([]);
const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now();
useEffect(() => {
if (!isCooldownActive || !cooldownDate) { setTimeLeft(null); return; }
const targetTime = cooldownDate.getTime();
const timer = setInterval(() => {
const diff = targetTime - Date.now();
if (diff <= 0) { setTimeLeft(null); clearInterval(timer); return; }
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${hours}h ${mins}m ${secs}s`);
}, 1000);
return () => clearInterval(timer);
}, [account?.cooldownExpiresAt, isCooldownActive]);
const avatarSrc = account?.localAvatar ? `steam-resource://${account.localAvatar}` : (account?.avatar || '');
const [imgSrc, setImgSrc] = useState(avatarSrc);
useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]);
const handleRefresh = async () => {
setIsRefreshing(true);
await scrapeAccount(account.steamId);
setIsRefreshing(false);
};
const handleOpenShare = async () => {
setIsShareOpen(true);
try {
const [users, selfInfo] = await Promise.all([
getServerUsers(),
(window as any).electronAPI.getServerUserInfo()
]);
const filtered = (Array.isArray(users) ? users : []).filter(u =>
u.steamId !== selfInfo.steamId && u.steamId !== account.steamId
);
setServerUsers(filtered);
} catch (e) {}
};
const handleShare = async () => {
if (!targetUserId) return;
setIsSharing(true);
try {
await shareAccountWithUser(account.steamId, targetUserId);
setTargetUserId('');
} catch (e: any) { alert(e.message || "Failed to share account");
} finally { setIsSharing(false); }
};
const handleRevoke = async (targetSteamId: string) => {
if (!window.confirm("Revoke access for this user?")) return;
try { await revokeAccountAccess(account.steamId, targetSteamId);
} catch (e: any) { alert(e.message); }
};
const handleRevokeAll = async () => {
if (!window.confirm("Completely stop sharing this account?")) return;
try { await revokeAllAccountAccess(account.steamId); setIsShareOpen(false);
} catch (e: any) { alert(e.message); }
};
const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0);
// Primary account check
const isPrimaryAccount = serverConfig?.serverSteamId === account.steamId;
// Refined Shared Logic
const isSharedWithYou = account?._id.startsWith('shared_');
const hasSharedMembers = (account as any).sharedWith && (account as any).sharedWith.length > 0;
const showCommunityIcon = isSharedWithYou || hasSharedMembers;
return (
<TableRow sx={{ '&:hover': { background: 'action.hover' }, borderBottom: '1px solid', borderColor: 'divider' }}>
<TableCell>
<Box sx={{ position: 'relative' }}>
<Avatar src={imgSrc} variant="square" sx={{ width: 32, height: 32, border: '1px solid', borderColor: 'divider' }} />
{isPrimaryAccount && (
<Tooltip title="Primary Community Account">
<WorkspacePremiumIcon sx={{ position: 'absolute', top: -8, left: -8, fontSize: 18, color: '#FFD700', filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.5))' }} />
</Tooltip>
)}
{showCommunityIcon && (
<Tooltip title={isSharedWithYou ? "Remote Shared Account" : "Actively Shared with Community"}>
<PeopleIcon sx={{ position: 'absolute', bottom: -4, right: -4, fontSize: 14, color: 'primary.main', bgcolor: 'background.default', borderRadius: '50%', border: '1px solid', borderColor: 'divider', p: 0.2 }} />
</Tooltip>
)}
</Box>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: isBanned ? 'error.main' : 'text.primary' }}>{account?.personaName || 'Unknown'}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{account?.steamId}</Typography>
</TableCell>
<TableCell>
{isBanned ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'error.main' }}>
<GppBadIcon sx={{ fontSize: 16 }} /><Typography variant="caption" sx={{ fontWeight: 'bold' }}>BANNED</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{account?.vacBanned && <Chip label="VAC" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold' }} />}
{account?.gameBans ? account.gameBans > 0 && <Chip label={`${account.gameBans} GAME`} size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold' }} /> : null}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'success.main' }}>
<ShieldIcon sx={{ fontSize: 16 }} /><Typography variant="caption" sx={{ fontWeight: 'bold' }}>SECURE</Typography>
</Box>
)}
</TableCell>
<TableCell>
{account?.authError ? (
<Box sx={{ display: 'flex', alignItems: 'center', color: 'warning.main', gap: 0.5 }}>
<LockResetIcon sx={{ fontSize: 16 }} /><Typography variant="body2" sx={{ fontWeight: 'bold' }}>Needs Re-auth</Typography>
</Box>
) : isCooldownActive ? (
<Box sx={{ display: 'flex', alignItems: 'center', color: 'primary.main', gap: 0.5 }}>
<TimerIcon sx={{ fontSize: 16 }} /><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{timeLeft}</Typography>
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Available</Typography>
)}
</TableCell>
<TableCell align="right">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, alignItems: 'center' }}>
{account.loginName && (
<Button
variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')}
sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 }, minWidth: 60 }}
>LOGIN</Button>
)}
<Tooltip title={account.steamLoginSecure && !account.authError ? "Tracking active" : "Authenticate for cooldowns"}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small" onClick={onAuth} disabled={!!(account.steamLoginSecure && !account.authError)}
sx={{
color: account.steamLoginSecure && !account.authError ? 'success.main' : (account.authError ? 'error.main' : 'warning.main'),
border: '1px solid', borderColor: account.steamLoginSecure && !account.authError ? 'success.main' : 'divider',
borderRadius: 1, background: account.steamLoginSecure && !account.authError ? 'rgba(163, 207, 6, 0.1)' : 'transparent'
}}
>
{account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)}
</IconButton>
{account.steamLoginSecure && !account.authError && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem' }}>TRACKING</Typography>
<IconButton size="small" onClick={handleRefresh} disabled={isRefreshing} sx={{ p: 0.2, color: 'text.secondary', '&:hover': { color: 'primary.main' } }}>
{isRefreshing ? <CircularProgress size={10} color="inherit" /> : <SyncIcon sx={{ fontSize: 12 }} />}
</IconButton>
</Box>
)}
</Box>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 0.5 }} />
<IconButton size="small" onClick={handleOpenShare} disabled={!serverConfig?.token}><ShareIcon fontSize="inherit" sx={{ color: 'primary.main' }}/></IconButton>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={() => (window as any).electronAPI.openExternal(account?.profileUrl || '')}><OpenInNewIcon fontSize="inherit"/></IconButton>
<IconButton size="small" sx={{ color: 'error.main' }} onClick={() => onDelete(account?._id || '')}><DeleteIcon fontSize="inherit"/></IconButton>
</Box>
<Dialog open={isShareOpen} onClose={() => setIsShareOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Permissions</DialogTitle>
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>GRANT ACCESS</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: 'text.secondary' }}>Select User</InputLabel>
<Select
value={targetUserId} label="Select User" onChange={(e) => setTargetUserId(e.target.value as string)}
sx={{ bgcolor: 'rgba(0,0,0,0.1)', color: 'text.primary' }}
>
{serverUsers
.filter(u => !(account as any).sharedWith?.find((sw: any) => sw.steamId === u.steamId))
.map(user => (
<MenuItem key={user.steamId} value={user.steamId}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><Avatar src={user.avatar} sx={{ width: 24, height: 24 }} />{user.personaName}</Box>
</MenuItem>
))}
{serverUsers.length === 0 && <MenuItem disabled>No eligible users found</MenuItem>}
</Select>
</FormControl>
<Button onClick={handleShare} variant="contained" disabled={!targetUserId || isSharing} sx={{ minWidth: 80 }}>{isSharing ? <CircularProgress size={16} color="inherit" /> : "Add"}</Button>
</Box>
<Divider sx={{ my: 2, borderColor: 'divider' }} />
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>CURRENT ACCESS</Typography>
<List size="small" sx={{ bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1, mb: 2 }}>
{(account as any).sharedWith?.map((sw: any) => (
<ListItem key={sw.steamId} dense divider sx={{ borderColor: 'divider' }}>
<Avatar src={sw.avatar} sx={{ width: 24, height: 24, mr: 1 }} />
<ListItemText primary={sw.personaName} primaryTypographyProps={{ variant: 'body2', sx: { fontWeight: 'bold' } }} />
<ListItemSecondaryAction>
<IconButton size="small" color="error" onClick={() => handleRevoke(sw.steamId)}><DeleteIcon fontSize="inherit" /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
{(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && (
<Typography variant="caption" align="center" sx={{ display: 'block', p: 2, opacity: 0.6 }}>Not shared with anyone yet.</Typography>
)}
</List>
{(account as any).sharedWith?.length > 0 && (
<Button fullWidth variant="outlined" color="error" size="small" onClick={handleRevokeAll} startIcon={<GppBadIcon />}>Revoke All Shared Access</Button>
)}
</DialogContent>
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}><Button onClick={() => setIsShareOpen(false)} color="inherit" variant="contained">Done</Button></DialogActions>
</Dialog>
</TableCell>
</TableRow>
);
};
export default Dashboard;

View File

@@ -1,234 +0,0 @@
const AccountRow: React.FC<{
account: Account,
onDelete: (id: string) => void,
onSwitch: (login: string) => void,
onAuth: () => void
}> = ({ account, onDelete, onSwitch, onAuth }) => {
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig } = useAccounts();
const [timeLeft, setTimeLeft] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [targetUserId, setTargetUserId] = useState('');
const [isSharing, setIsSharing] = useState(false);
const [serverUsers, setServerUsers] = useState<any[]>([]);
const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now();
useEffect(() => {
if (!isCooldownActive || !cooldownDate) {
setTimeLeft(null);
return;
}
const targetTime = cooldownDate.getTime();
const timer = setInterval(() => {
const diff = targetTime - Date.now();
if (diff <= 0) { setTimeLeft(null); clearInterval(timer); return; }
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${hours}h ${mins}m ${secs}s`);
}, 1000);
return () => clearInterval(timer);
}, [account?.cooldownExpiresAt, isCooldownActive]);
const avatarSrc = account?.localAvatar
? `steam-resource://${account.localAvatar}`
: (account?.avatar || '');
const [imgSrc, setImgSrc] = useState(avatarSrc);
useEffect(() => {
setImgSrc(avatarSrc);
}, [avatarSrc]);
const handleOpenShare = async () => {
setIsShareOpen(true);
try {
const [users, selfInfo] = await Promise.all([
getServerUsers(),
(window as any).electronAPI.getServerUserInfo()
]);
const filtered = (Array.isArray(users) ? users : []).filter(u =>
u.steamId !== selfInfo.steamId &&
u.steamId !== account.steamId
);
setServerUsers(filtered);
} catch (e) {}
};
const handleShare = async () => {
if (!targetUserId) return;
setIsSharing(true);
try {
await shareAccountWithUser(account.steamId, targetUserId);
setTargetUserId('');
} catch (e: any) {
alert(e.message || "Failed to share account");
} finally {
setIsSharing(false);
}
};
const handleRevoke = async (targetSteamId: string) => {
if (!window.confirm("Revoke access for this user?")) return;
try {
await revokeAccountAccess(account.steamId, targetSteamId);
} catch (e: any) { alert(e.message); }
};
const handleRevokeAll = async () => {
if (!window.confirm("Completely stop sharing this account with the community?")) return;
try {
await revokeAllAccountAccess(account.steamId);
setIsShareOpen(false);
} catch (e: any) { alert(e.message); }
};
const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0);
const isShared = account?._id.startsWith('shared_');
return (
<TableRow sx={{ '&:hover': { background: 'action.hover' }, borderBottom: '1px solid', borderColor: 'divider' }}>
<TableCell>
<Box sx={{ position: 'relative' }}>
<Avatar src={imgSrc} variant="square" sx={{ width: 32, height: 32, border: '1px solid', borderColor: 'divider' }} />
{isShared && (
<Tooltip title="Community Shared Account">
<PeopleIcon sx={{ position: 'absolute', bottom: -4, right: -4, fontSize: 14, color: 'primary.main', bgcolor: 'background.default', borderRadius: '50%', border: '1px solid', borderColor: 'divider', p: 0.2 }} />
</Tooltip>
)}
</Box>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: isBanned ? 'error.main' : 'text.primary' }}>
{account?.personaName || 'Unknown'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{account?.steamId}</Typography>
</TableCell>
<TableCell>
{isBanned ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'error.main' }}>
<GppBadIcon sx={{ fontSize: 16 }} />
<Typography variant="caption" sx={{ fontWeight: 'bold', letterSpacing: '0.5px' }}>ACCOUNT BANNED</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{account?.vacBanned && (
<Chip label="VAC" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold', borderRadius: 0.5 }} />
)}
{account?.gameBans ? account.gameBans > 0 && (
<Chip label={`${account.gameBans} GAME`} size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold', borderRadius: 0.5 }} />
) : null}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'success.main' }}>
<ShieldIcon sx={{ fontSize: 16 }} />
<Typography variant="caption" sx={{ fontWeight: 'bold', letterSpacing: '0.5px' }}>SECURE</Typography>
</Box>
)}
</TableCell>
<TableCell>
{account?.authError ? (
<Box sx={{ display: 'flex', alignItems: 'center', color: 'warning.main', gap: 0.5 }}>
<LockResetIcon sx={{ fontSize: 16 }} />
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>Needs Re-auth</Typography>
</Box>
) : isCooldownActive ? (
<Box sx={{ display: 'flex', alignItems: 'center', color: 'primary.main', gap: 0.5 }}>
<TimerIcon sx={{ fontSize: 16 }} />
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{timeLeft}</Typography>
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Available</Typography>
)}
</TableCell>
<TableCell align="right">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, alignItems: 'center' }}>
{account.loginName && (
<Button
variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')}
sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 }, minWidth: 60 }}
>
LOGIN
</Button>
)}
<Tooltip title={account.steamLoginSecure && !account.authError ? "Session valid - Tracking active" : (account.steamLoginSecure ? "Refresh scraper session" : "Authenticate for cooldown tracking")}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small" onClick={onAuth}
disabled={!!(account.steamLoginSecure && !account.authError)}
sx={{
color: account.steamLoginSecure && !account.authError ? 'success.main' : (account.authError ? 'error.main' : 'warning.main'),
border: '1px solid', borderColor: account.steamLoginSecure && !account.authError ? 'success.main' : 'divider',
borderRadius: 1, background: account.steamLoginSecure && !account.authError ? 'rgba(163, 207, 6, 0.1)' : 'transparent'
}}
>
{account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)}
</IconButton>
{account.steamLoginSecure && !account.authError && (
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem', letterSpacing: '0.5px' }}>
TRACKING
</Typography>
)}
</Box>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 0.5 }} />
<IconButton size="small" onClick={handleOpenShare} disabled={!serverConfig?.token}><ShareIcon fontSize="inherit" sx={{ color: 'primary.main' }}/></IconButton>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={() => (window as any).electronAPI.openExternal(account?.profileUrl || '')}><OpenInNewIcon fontSize="inherit"/></IconButton>
<IconButton size="small" sx={{ color: 'error.main' }} onClick={() => onDelete(account?._id || '')}><DeleteIcon fontSize="inherit"/></IconButton>
</Box>
<Dialog open={isShareOpen} onClose={() => setIsShareOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Account Permissions</DialogTitle>
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>GRANT ACCESS</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: 'text.secondary' }}>Select User</InputLabel>
<Select
value={targetUserId} label="Select User" onChange={(e) => setTargetUserId(e.target.value as string)}
sx={{ bgcolor: 'rgba(0,0,0,0.1)', color: 'text.primary' }}
>
{serverUsers
.filter(u => !(account as any).sharedWith?.find((sw: any) => sw.steamId === u.steamId))
.map(user => (
<MenuItem key={user.steamId} value={user.steamId}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><Avatar src={user.avatar} sx={{ width: 24, height: 24 }} />{user.personaName}</Box>
</MenuItem>
))}
{serverUsers.length === 0 && <MenuItem disabled>No eligible users found</MenuItem>}
</Select>
</FormControl>
<Button onClick={handleShare} variant="contained" disabled={!targetUserId || isSharing} sx={{ minWidth: 80 }}>
{isSharing ? <CircularProgress size={16} color="inherit" /> : "Add"}
</Button>
</Box>
<Divider sx={{ my: 2, borderColor: 'divider' }} />
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>CURRENT ACCESS</Typography>
<List size="small" sx={{ bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1, mb: 2 }}>
{(account as any).sharedWith?.map((sw: any) => (
<ListItem key={sw.steamId} dense divider sx={{ borderColor: 'divider' }}>
<Avatar src={sw.avatar} sx={{ width: 24, height: 24, mr: 1 }} />
<ListItemText primary={sw.personaName} primaryTypographyProps={{ variant: 'body2', sx: { fontWeight: 'bold' } }} />
<ListItemSecondaryAction>
<IconButton size="small" color="error" onClick={() => handleRevoke(sw.steamId)}><DeleteIcon fontSize="inherit" /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
{(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && (
<Typography variant="caption" align="center" sx={{ display: 'block', p: 2, opacity: 0.6 }}>Not shared with anyone yet.</Typography>
)}
</List>
{(account as any).sharedWith?.length > 0 && (
<Button fullWidth variant="outlined" color="error" size="small" onClick={handleRevokeAll} startIcon={<GppBadIcon />}>Revoke All Shared Access</Button>
)}
</DialogContent>
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}><Button onClick={() => setIsShareOpen(false)} color="inherit" variant="contained">Done</Button></DialogActions>
</Dialog>
</TableCell>
</TableRow>
);
};

View File

@@ -1,84 +0,0 @@
import React from 'react';
import { Box, Button, Typography, Container, Paper } from '@mui/material';
import SteamIcon from '@mui/icons-material/SportsEsports';
const LoginPage: React.FC = () => {
const handleSteamLogin = () => {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
window.location.href = `${apiUrl}/api/auth/steam`;
};
return (
<Box sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'background.default',
position: 'relative',
overflow: 'hidden',
p: 2
}}>
{/* Background Glow */}
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '40vh',
background: (theme) => `radial-gradient(circle at 50% 0%, ${theme.palette.primary.main}22 0%, transparent 70%)`,
zIndex: 0
}} />
<Container maxWidth="xs" sx={{ position: 'relative', zIndex: 1 }}>
<Paper elevation={24} sx={{
p: 5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
backgroundColor: 'background.paper',
backdropFilter: 'blur(10px)',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2
}}>
<SteamIcon sx={{ fontSize: 64, color: 'primary.main' }} />
<Typography variant="h4" component="h1" align="center" sx={{ fontWeight: 'bold', letterSpacing: '1px', color: 'text.primary' }}>
ULTIMATE BAN TRACKER
</Typography>
<Typography variant="body1" align="center" sx={{ color: 'text.secondary' }}>
Sign in with Steam to access your community dashboard and start tracking.
</Typography>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<SteamIcon />}
onClick={handleSteamLogin}
sx={{
height: 56,
fontSize: '1.1rem',
fontWeight: 'bold',
background: (theme) => `linear-gradient(to bottom, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
'&:hover': {
filter: 'brightness(1.1)'
}
}}
>
Sign in through STEAM
</Button>
<Typography variant="caption" align="center" sx={{ color: 'text.secondary', opacity: 0.7 }}>
We only use your Steam ID for identification. Your credentials remain private and secure.
</Typography>
</Paper>
</Container>
</Box>
);
};
export default LoginPage;

View File

@@ -1,90 +0,0 @@
import { createTheme } from '@mui/material/styles';
import type { ThemeOptions } from '@mui/material/styles';
export type ThemeType = 'steam' | 'mocha' | 'latte' | 'nord' | 'tokyo';
const commonSettings: ThemeOptions = {
typography: {
fontFamily: '"Motiva Sans", "Roboto", "Helvetica", "Arial", sans-serif',
h6: { fontWeight: 700, letterSpacing: '0.5px' },
button: { textTransform: 'none', fontWeight: 600 },
},
shape: { borderRadius: 4 },
components: {
MuiButton: {
styleOverrides: {
root: { borderRadius: 2, boxShadow: 'none', '&:hover': { boxShadow: 'none' } },
},
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'none' },
},
},
},
};
export const themes: Record<ThemeType, ThemeOptions> = {
steam: {
...commonSettings,
palette: {
mode: 'dark',
primary: { main: '#66c0f4', dark: '#1a9fff' },
secondary: { main: '#4c6b22', dark: '#3d541b' },
background: { default: '#171a21', paper: '#1b2838' },
text: { primary: '#ffffff', secondary: '#8f98a0' },
success: { main: '#a3cf06' },
error: { main: '#ff4c4c' },
},
},
mocha: {
...commonSettings,
palette: {
mode: 'dark',
primary: { main: '#89b4fa', dark: '#74c7ec' },
secondary: { main: '#a6e3a1', dark: '#94e2d5' },
background: { default: '#11111b', paper: '#1e1e2e' },
text: { primary: '#cdd6f4', secondary: '#9399b2' },
success: { main: '#a6e3a1' },
error: { main: '#f38ba8' },
},
},
latte: {
...commonSettings,
palette: {
mode: 'light',
primary: { main: '#1e66f5', dark: '#179299' },
secondary: { main: '#40a02b', dark: '#40a02b' },
background: { default: '#eff1f5', paper: '#e6e9ef' },
text: { primary: '#4c4f69', secondary: '#6c6f85' },
success: { main: '#40a02b' },
error: { main: '#d20f39' },
},
},
nord: {
...commonSettings,
palette: {
mode: 'dark',
primary: { main: '#88c0d0', dark: '#81a1c1' },
secondary: { main: '#a3be8c', dark: '#8fbcbb' },
background: { default: '#2e3440', paper: '#3b4252' },
text: { primary: '#eceff4', secondary: '#d8dee9' },
success: { main: '#a3be8c' },
error: { main: '#bf616a' },
},
},
tokyo: {
...commonSettings,
palette: {
mode: 'dark',
primary: { main: '#7aa2f7', dark: '#3d59a1' },
secondary: { main: '#9ece6a', dark: '#73daca' },
background: { default: '#1a1b26', paper: '#24283b' },
text: { primary: '#c0caf5', secondary: '#9aa5ce' },
success: { main: '#9ece6a' },
error: { main: '#f7768e' },
},
},
};
export const getTheme = (type: ThemeType) => createTheme(themes[type]);

View File

@@ -1,71 +0,0 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { getTheme } from './SteamTheme';
import type { ThemeType } from './SteamTheme';
interface ThemeContextType {
currentTheme: ThemeType;
setTheme: (theme: ThemeType) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const AppThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState<ThemeType>('steam');
useEffect(() => {
// Load theme from store on startup
const loadTheme = async () => {
const api = (window as any).electronAPI;
if (api?.getServerConfig) {
const config = await api.getServerConfig();
if (config?.theme) setCurrentTheme(config.theme);
}
};
loadTheme();
}, []);
const setTheme = async (theme: ThemeType) => {
setCurrentTheme(theme);
const api = (window as any).electronAPI;
if (api?.updateServerConfig) {
await api.updateServerConfig({ theme });
}
if (api?.updateAppIcon) {
try {
await api.updateAppIcon(theme);
} catch (e) { }
}
};
useEffect(() => {
const updateIcon = async () => {
const api = (window as any).electronAPI;
if (api?.updateAppIcon && currentTheme) {
try {
await api.updateAppIcon(currentTheme);
} catch (e) {
console.warn("[ThemeContext] updateAppIcon failed (likely not registered yet)");
}
}
};
updateIcon();
}, [currentTheme]);
const theme = useMemo(() => getTheme(currentTheme), [currentTheme]);
return (
<ThemeContext.Provider value={{ currentTheme, setTheme }}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeContext.Provider>
);
};
export const useAppTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useAppTheme must be used within AppThemeProvider');
return context;
};

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,19 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: './',
build: {
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
'vendor-core': ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
}
}
}
}
})

Binary file not shown.