init
Some checks failed
Build and Release / build (push) Has been cancelled

This commit is contained in:
2026-02-21 01:48:48 +01:00
commit 64fe49e58e
47 changed files with 13695 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
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: 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${{ github.run_number }}
name: Release v${{ github.run_number }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# 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/

25
LICENSE Normal file
View File

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

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# 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.

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

View File

@@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="64" fill="#171A21"/>
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,563 @@
"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') });
// --- Server Configuration ---
let backend = null;
const initBackend = () => {
const config = store.get('serverConfig');
if (config && config.enabled && config.url) {
console.log(`[Backend] Initializing with URL: ${config.url}`);
backend = new backend_1.BackendService(config.url, config.token);
}
else {
backend = null;
}
};
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;
}
};
electron_1.protocol.registerSchemesAsPrivileged([
{ scheme: 'steam-resource', privileges: { secure: true, standard: true, supportFetchAPI: true } }
]);
// --- Main Window ---
let mainWindow = null;
function createWindow() {
mainWindow = new electron_1.BrowserWindow({
width: 1280,
height: 800,
title: "Ultimate Ban Tracker Desktop",
backgroundColor: '#171a21',
autoHideMenuBar: true,
webPreferences: {
preload: path_1.default.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.setMenu(null);
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
}
else {
mainWindow.loadFile(path_1.default.join(__dirname, '..', 'dist', 'index.html'));
}
}
// --- Sync Logic ---
const syncAccounts = async () => {
initBackend();
let accounts = store.get('accounts');
let hasChanges = false;
// 1. PULL SHARED ACCOUNTS FROM SERVER
if (backend) {
console.log('[Sync] Phase 1: Pulling from server...');
try {
const shared = await backend.getSharedAccounts();
for (const s of shared) {
const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) {
console.log(`[Sync] Discovered new account on server: ${s.personaName}`);
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 ? true : false,
status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString()
});
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);
if (sDate > lDate) {
console.log(`[Sync] Updating session for ${exists.personaName} (Server is newer)`);
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;
}
if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
exists.cooldownExpiresAt = s.cooldownExpiresAt;
hasChanges = true;
}
}
}
}
catch (e) {
console.error('[Sync] Pull failed');
}
}
// BROADCAST PULL RESULTS IMMEDIATELY
if (hasChanges) {
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
}
if (accounts.length === 0)
return;
// 2. BACKGROUND STEALTH CHECKS
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
const updatedAccounts = [...accounts];
let scrapeChanges = false;
for (const account of updatedAccounts) {
try {
const now = new Date();
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
const hoursSinceCheck = (now.getTime() - lastCheck.getTime()) / 3600000;
if (hoursSinceCheck > 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);
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 hoursSinceScrape = (now.getTime() - lastScrape.getTime()) / 3600000;
if (hoursSinceScrape > 8) {
const jitter = Math.floor(Math.random() * 60000) + 5000;
await new Promise(r => setTimeout(r, jitter));
try {
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
account.authError = false;
account.lastScrapeTime = new Date().toISOString();
if (result.isActive) {
if (result.expiresAt) {
account.cooldownExpiresAt = result.expiresAt.toISOString();
}
else if (!account.cooldownExpiresAt) {
const placeholder = new Date();
placeholder.setHours(placeholder.getHours() + 24);
account.cooldownExpiresAt = placeholder.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) {
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);
}
console.log('[Sync] Sync cycle finished.');
};
const scheduleNextSync = () => {
const delay = isDev ? 120000 : (Math.random() * 30 * 60000) + 30 * 60000;
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, delay);
};
// --- Steam Auto-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);
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId,
personaName: profile.personaName || local.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) {
store.set('accounts', currentAccounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', currentAccounts);
}
};
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();
initBackend();
setTimeout(syncAccounts, 5000);
scheduleNextSync();
steam_client_1.steamClient.startWatching(handleLocalAccountsFound);
});
// --- IPC Handlers ---
console.log('[Main] Registering 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 Ban Tracker Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false;
const saveServerAuth = (token) => {
if (captured)
return;
captured = true;
console.log('[ServerAuth] Securely captured token');
let serverSteamId = undefined;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
serverSteamId = payload.steamId;
}
catch (e) { }
const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
initBackend();
authWindow.close();
resolve(true);
};
// METHOD 1: Sniff HTTP Headers
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 });
});
// METHOD 2: Watch Window Title (Fallback)
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(); return true; });
electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
try {
initBackend();
// OPTIMIZATION: Check community server first
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 ? true : false,
status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]);
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]);
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);
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));
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('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
electron_1.ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
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);
};
electron_1.ipcMain.handle('switch-account', async (event, 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;
}
});
electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url));
electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => {
const loginSession = electron_1.session.fromPartition('persist:steam-login');
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
return new Promise((resolve) => {
const loginWindow = new electron_1.BrowserWindow({
width: 800,
height: 700,
parent: mainWindow || undefined,
modal: true,
title: 'Login to Steam (Ensure "Remember Me" is checked!)',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition: 'persist:steam-login'
}
});
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) {
console.error(`[Auth] ID Mismatch! Expected ${expectedSteamId}, got ${steamId}`);
return;
}
clearInterval(checkCookie);
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
console.log(`[Auth] Captured session for SteamID: ${steamId}`);
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 {
console.log(`[Auth] Performing initial scrape for ${account.personaName}...`);
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, cookieString);
account.lastScrapeTime = new Date().toISOString();
if (result.isActive && result.expiresAt) {
account.cooldownExpiresAt = result.expiresAt.toISOString();
}
else if (!result.isActive) {
account.cooldownExpiresAt = undefined;
}
}
catch (e) {
console.error('[Auth] Initial scrape failed:', e);
}
initBackend();
if (backend)
await backend.shareAccount(account);
store.set('accounts', accounts);
if (mainWindow)
mainWindow.webContents.send('accounts-updated', accounts);
loginWindow.close();
resolve(true);
}
}
}
}
catch (error) { }
}, 1000);
loginWindow.on('closed', () => {
clearInterval(checkCookie);
resolve(false);
});
});
});
electron_1.app.on('window-all-closed', () => { if (process.platform !== 'darwin')
electron_1.app.quit(); });

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
"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),
openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', url),
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'),
getCommunityAccounts: () => electron_1.ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => electron_1.ipcRenderer.invoke('get-server-users'),
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

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

@@ -0,0 +1,104 @@
"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
}, { headers: this.headers });
}
catch (e) {
console.error('[Backend] Failed to share account');
}
}
async pushCooldown(steamId, cooldownExpiresAt) {
if (!this.token)
return;
try {
await axios_1.default.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt
}, { 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');
}
}
}
exports.BackendService = BackendService;

View File

@@ -0,0 +1,91 @@
"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 = void 0;
const axios_1 = __importDefault(require("axios"));
const cheerio = __importStar(require("cheerio"));
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
});
const $ = cheerio.load(response.data);
if (response.data.includes('Sign In') || !response.data.includes('Personal Game Data')) {
throw new Error('Invalid or expired steamLoginSecure cookie');
}
// 1. Locate the specific table containing cooldown info
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'));
if (expirationIndex !== -1) {
const firstRow = $(table).find('tr').not(':has(th)').first();
const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
if (dateText && dateText !== '') {
const cleanDateText = dateText.replace(' GMT', ' UTC');
const parsed = new Date(cleanDateText);
if (!isNaN(parsed.getTime())) {
expirationDate = parsed;
}
}
}
});
if (expirationDate && expirationDate.getTime() > Date.now()) {
console.log(`[Scraper] Found active cooldown until: ${expirationDate.toISOString()}`);
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) {
console.error(`[Scraper] Error for ${steamId}:`, error.message);
throw error;
}
};
exports.scrapeCooldown = scrapeCooldown;

View File

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

@@ -0,0 +1,239 @@
"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'
];
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')
];
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');
}
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 }).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');
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;
accounts.push({
steamId: steamId64,
accountName: user.AccountName,
personaName: user.PersonaName,
timestamp: parseInt(user.Timestamp) || 0
});
}
if (this.onAccountsChanged)
this.onAccountsChanged(accounts);
}
catch (error) {
console.error('[SteamClient] Error parsing loginusers.vdf:', 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;
if (accounts && accounts[accountName]) {
return accounts[accountName];
}
}
catch (e) {
console.error('[SteamClient] Failed to extract config.vdf data');
}
return null;
}
injectAccountConfig(accountName, accountData) {
const configPath = this.getConfigVdfPath();
if (!configPath)
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: {} } } } } };
if (fs_1.default.existsSync(configPath)) {
try {
const content = fs_1.default.readFileSync(configPath, 'utf-8');
data = (0, simple_vdf_1.parse)(content);
}
catch (e) { }
}
// Ensure structure exists
if (!data.InstallConfigStore)
data.InstallConfigStore = {};
if (!data.InstallConfigStore.Software)
data.InstallConfigStore.Software = {};
if (!data.InstallConfigStore.Software.Valve)
data.InstallConfigStore.Software.Valve = {};
if (!data.InstallConfigStore.Software.Valve.Steam)
data.InstallConfigStore.Software.Valve.Steam = {};
if (!data.InstallConfigStore.Software.Valve.Steam.Accounts)
data.InstallConfigStore.Software.Valve.Steam.Accounts = {};
data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
try {
fs_1.default.writeFileSync(configPath, (0, simple_vdf_1.stringify)(data));
console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`);
}
catch (e) {
console.error('[SteamClient] Failed to write config.vdf');
}
}
async setAutoLoginUser(accountName, accountConfig, steamId) {
const platform = os_1.default.platform();
const loginUsersPath = this.getLoginUsersPath();
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: {} };
if (fs_1.default.existsSync(loginUsersPath)) {
try {
const content = fs_1.default.readFileSync(loginUsersPath, 'utf-8');
data = (0, simple_vdf_1.parse)(content);
}
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) {
console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
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 {
fs_1.default.writeFileSync(loginUsersPath, (0, simple_vdf_1.stringify)(data));
}
catch (e) {
console.error('[SteamClient] Failed to write loginusers.vdf');
}
}
if (accountConfig) {
this.injectAccountConfig(accountName, accountConfig);
}
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) {
let regData = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
if (fs_1.default.existsSync(regPath)) {
try {
const content = fs_1.default.readFileSync(regPath, 'utf-8');
regData = (0, simple_vdf_1.parse)(content);
}
catch (e) { }
}
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;
for (let i = 0; i < keys.length - 1; i++) {
if (!curr[keys[i]])
curr[keys[i]] = {};
curr = curr[keys[i]];
}
curr[keys[keys.length - 1]] = val;
};
const steamReg = ['Registry', 'HKCU', 'Software', 'Valve', 'Steam'];
setPath(regData, [...steamReg, 'AutoLoginUser'], accountName);
setPath(regData, [...steamReg, 'RememberPassword'], "1");
setPath(regData, [...steamReg, 'AlreadyLoggedIn'], "1");
setPath(regData, [...steamReg, 'WantsOfflineMode'], "0");
try {
fs_1.default.writeFileSync(regPath, (0, simple_vdf_1.stringify)(regData));
console.log(`[SteamClient] Registry updated: ${regPath}`);
}
catch (e) { }
}
}
return true;
}
}
exports.steamClient = new SteamClientService();

View File

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

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

@@ -0,0 +1 @@
{"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"}

600
frontend/electron/main.ts Normal file
View File

@@ -0,0 +1,600 @@
import { app, BrowserWindow, ipcMain, shell, session, protocol, net } from 'electron';
import path from 'path';
import Store from 'electron-store';
import { exec } from 'child_process';
import dotenv from 'dotenv';
import cron from 'node-cron';
import axios from 'axios';
import fs from 'fs';
import { pathToFileURL } from 'url';
import { fetchProfileData, scrapeBanStatus } from './services/steam-web';
import { scrapeCooldown } 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') });
// --- Server Configuration ---
let backend: BackendService | null = null;
const initBackend = () => {
const config = store.get('serverConfig');
if (config && config.enabled && config.url) {
console.log(`[Backend] Initializing with URL: ${config.url}`);
backend = new BackendService(config.url, config.token);
} else {
backend = null;
}
};
// --- Local Data Store ---
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;
}
interface ServerConfig {
url: string;
token?: string;
serverSteamId?: string;
enabled: boolean;
}
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;
}
};
protocol.registerSchemesAsPrivileged([
{ scheme: 'steam-resource', privileges: { secure: true, standard: true, supportFetchAPI: true } }
]);
// --- Main Window ---
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
title: "Ultimate Ban Tracker Desktop",
backgroundColor: '#171a21',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.setMenu(null);
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
}
// --- Sync Logic ---
const syncAccounts = async () => {
initBackend();
let accounts = store.get('accounts') as Account[];
let hasChanges = false;
// 1. PULL SHARED ACCOUNTS FROM SERVER
if (backend) {
console.log('[Sync] Phase 1: Pulling from server...');
try {
const shared = await backend.getSharedAccounts();
for (const s of shared) {
const exists = accounts.find(a => a.steamId === s.steamId);
if (!exists) {
console.log(`[Sync] Discovered new account on server: ${s.personaName}`);
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 ? true : false,
status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString()
});
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);
if (sDate > lDate) {
console.log(`[Sync] Updating session for ${exists.personaName} (Server is newer)`);
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;
}
if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
exists.cooldownExpiresAt = s.cooldownExpiresAt;
hasChanges = true;
}
}
}
} catch (e) {
console.error('[Sync] Pull failed');
}
}
// BROADCAST PULL RESULTS IMMEDIATELY
if (hasChanges) {
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
}
if (accounts.length === 0) return;
// 2. BACKGROUND STEALTH CHECKS
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
const updatedAccounts = [...accounts];
let scrapeChanges = false;
for (const account of updatedAccounts) {
try {
const now = new Date();
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
const hoursSinceCheck = (now.getTime() - lastCheck.getTime()) / 3600000;
if (hoursSinceCheck > 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;
}
if (account.loginName) {
const config = steamClient.extractAccountConfig(account.loginName);
if (config) {
account.loginConfig = config;
account.sessionUpdatedAt = new Date().toISOString();
}
}
if (backend) await backend.shareAccount(account);
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 hoursSinceScrape = (now.getTime() - lastScrape.getTime()) / 3600000;
if (hoursSinceScrape > 8) {
const jitter = Math.floor(Math.random() * 60000) + 5000;
await new Promise(r => setTimeout(r, jitter));
try {
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
account.authError = false;
account.lastScrapeTime = new Date().toISOString();
if (result.isActive) {
if (result.expiresAt) {
account.cooldownExpiresAt = result.expiresAt.toISOString();
} else if (!account.cooldownExpiresAt) {
const placeholder = new Date();
placeholder.setHours(placeholder.getHours() + 24);
account.cooldownExpiresAt = placeholder.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);
}
console.log('[Sync] Sync cycle finished.');
};
const scheduleNextSync = () => {
const delay = isDev ? 120000 : (Math.random() * 30 * 60000) + 30 * 60000;
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, delay);
};
// --- Steam Auto-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);
currentAccounts.push({
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
steamId: local.steamId,
personaName: profile.personaName || local.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) {
store.set('accounts', currentAccounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
}
};
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();
initBackend();
setTimeout(syncAccounts, 5000);
scheduleNextSync();
steamClient.startWatching(handleLocalAccountsFound);
});
// --- IPC Handlers ---
console.log('[Main] Registering 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 Ban Tracker Server',
webPreferences: { nodeIntegration: false, contextIsolation: true }
});
authWindow.loadURL(`${config.url}/auth/steam`);
let captured = false;
const saveServerAuth = (token: string) => {
if (captured) return;
captured = true;
console.log('[ServerAuth] Securely captured token');
let serverSteamId = undefined;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString());
serverSteamId = payload.steamId;
} catch (e) {}
const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
initBackend();
authWindow.close();
resolve(true);
};
// METHOD 1: Sniff HTTP Headers
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 });
});
// METHOD 2: Watch Window Title (Fallback)
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(); return true; });
ipcMain.handle('add-account', async (event, { identifier }) => {
try {
initBackend();
// OPTIMIZATION: Check community server first
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 ? true : false,
status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
lastBanCheck: new Date().toISOString()
};
store.set('accounts', [...accounts, newAccount]);
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]);
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); 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));
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('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
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);
};
ipcMain.handle('switch-account', async (event, 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; }
});
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
const loginSession = session.fromPartition('persist:steam-login');
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
return new Promise<boolean>((resolve) => {
const loginWindow = new BrowserWindow({
width: 800,
height: 700,
parent: mainWindow || undefined,
modal: true,
title: 'Login to Steam (Ensure "Remember Me" is checked!)',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition: 'persist:steam-login'
}
});
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) {
console.error(`[Auth] ID Mismatch! Expected ${expectedSteamId}, got ${steamId}`);
return;
}
clearInterval(checkCookie);
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
console.log(`[Auth] Captured session for SteamID: ${steamId}`);
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 {
console.log(`[Auth] Performing initial scrape for ${account.personaName}...`);
const result = await scrapeCooldown(account.steamId, cookieString);
account.lastScrapeTime = new Date().toISOString();
if (result.isActive && result.expiresAt) {
account.cooldownExpiresAt = result.expiresAt.toISOString();
} else if (!result.isActive) {
account.cooldownExpiresAt = undefined;
}
} catch (e) {
console.error('[Auth] Initial scrape failed:', e);
}
initBackend();
if (backend) await backend.shareAccount(account);
store.set('accounts', accounts);
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
loginWindow.close();
resolve(true);
}
}
}
} catch (error) { }
}, 1000);
loginWindow.on('closed', () => {
clearInterval(checkCookie);
resolve(false);
});
});
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });

View File

@@ -0,0 +1,28 @@
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),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
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'),
getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => ipcRenderer.invoke('get-server-users'),
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

@@ -0,0 +1,94 @@
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
}, { headers: this.headers });
} catch (e) {
console.error('[Backend] Failed to share account');
}
}
public async pushCooldown(steamId: string, cooldownExpiresAt?: string) {
if (!this.token) return;
try {
await axios.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
cooldownExpiresAt
}, { 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');
}
}
}

View File

@@ -0,0 +1,67 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
export interface CooldownData {
isActive: boolean;
expiresAt?: Date;
}
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
});
const $ = cheerio.load(response.data);
if (response.data.includes('Sign In') || !response.data.includes('Personal Game Data')) {
throw new Error('Invalid or expired steamLoginSecure cookie');
}
// 1. Locate the specific table containing cooldown info
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'));
if (expirationIndex !== -1) {
const firstRow = $(table).find('tr').not(':has(th)').first();
const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
if (dateText && dateText !== '') {
const cleanDateText = dateText.replace(' GMT', ' UTC');
const parsed = new Date(cleanDateText);
if (!isNaN(parsed.getTime())) {
expirationDate = parsed;
}
}
}
});
if (expirationDate && (expirationDate as Date).getTime() > Date.now()) {
console.log(`[Scraper] Found active cooldown until: ${(expirationDate as Date).toISOString()}`);
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) {
console.error(`[Scraper] Error for ${steamId}:`, error.message);
throw error;
}
};

View File

@@ -0,0 +1,251 @@
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'
];
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')
];
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');
}
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 }).on('change', () => {
this.readLocalAccounts();
});
}
}
private readLocalAccounts() {
const filePath = this.getLoginUsersPath();
if (!filePath || !fs.existsSync(filePath)) return;
try {
const content = fs.readFileSync(filePath, 'utf-8');
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;
accounts.push({
steamId: steamId64,
accountName: user.AccountName,
personaName: user.PersonaName,
timestamp: parseInt(user.Timestamp) || 0
});
}
if (this.onAccountsChanged) this.onAccountsChanged(accounts);
} catch (error) {
console.error('[SteamClient] Error parsing loginusers.vdf:', 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;
if (accounts && accounts[accountName]) {
return accounts[accountName];
}
} catch (e) {
console.error('[SteamClient] Failed to extract config.vdf data');
}
return null;
}
public injectAccountConfig(accountName: string, accountData: any) {
const configPath = this.getConfigVdfPath();
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: {} } } } } };
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
data = parse(content) as any;
} catch (e) { }
}
// Ensure structure exists
if (!data.InstallConfigStore) data.InstallConfigStore = {};
if (!data.InstallConfigStore.Software) data.InstallConfigStore.Software = {};
if (!data.InstallConfigStore.Software.Valve) data.InstallConfigStore.Software.Valve = {};
if (!data.InstallConfigStore.Software.Valve.Steam) data.InstallConfigStore.Software.Valve.Steam = {};
if (!data.InstallConfigStore.Software.Valve.Steam.Accounts) data.InstallConfigStore.Software.Valve.Steam.Accounts = {};
data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
try {
fs.writeFileSync(configPath, stringify(data));
console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`);
} catch (e) {
console.error('[SteamClient] Failed to write config.vdf');
}
}
public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise<boolean> {
const platform = os.platform();
const loginUsersPath = this.getLoginUsersPath();
if (loginUsersPath) {
const configDir = path.dirname(loginUsersPath);
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
let data: any = { users: {} };
if (fs.existsSync(loginUsersPath)) {
try {
const content = fs.readFileSync(loginUsersPath, 'utf-8');
data = parse(content) as any;
} 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) {
console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
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 {
fs.writeFileSync(loginUsersPath, stringify(data));
} catch (e) {
console.error('[SteamClient] Failed to write loginusers.vdf');
}
}
if (accountConfig) {
this.injectAccountConfig(accountName, accountConfig);
}
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) {
let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
if (fs.existsSync(regPath)) {
try {
const content = fs.readFileSync(regPath, 'utf-8');
regData = parse(content) as any;
} catch (e) { }
} else {
const regDir = path.dirname(regPath);
if (!fs.existsSync(regDir)) fs.mkdirSync(regDir, { recursive: true });
}
const setPath = (obj: any, keys: string[], val: string) => {
let curr = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!curr[keys[i]!]) curr[keys[i]!] = {};
curr = curr[keys[i]!];
}
curr[keys[keys.length - 1]!] = val;
};
const steamReg = ['Registry', 'HKCU', 'Software', 'Valve', 'Steam'];
setPath(regData, [...steamReg, 'AutoLoginUser'], accountName);
setPath(regData, [...steamReg, 'RememberPassword'], "1");
setPath(regData, [...steamReg, 'AlreadyLoggedIn'], "1");
setPath(regData, [...steamReg, 'WantsOfflineMode'], "0");
try {
fs.writeFileSync(regPath, stringify(regData));
console.log(`[SteamClient] Registry updated: ${regPath}`);
} catch (e) { }
}
}
return true;
}
}
export const steamClient = new SteamClientService();

View File

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

@@ -0,0 +1,16 @@
{
"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

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

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
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,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9541
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

108
frontend/package.json Normal file
View File

@@ -0,0 +1,108 @@
{
"name": "ultimate-ban-tracker-desktop",
"description": "Professional Steam Account Manager & Ban Tracker",
"version": "1.0.0",
"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/**/*"
],
"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"
}
}

1
frontend/public/vite.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#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;
}

13
frontend/src/App.tsx Normal file
View File

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

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

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

@@ -0,0 +1,175 @@
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;
}
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>;
openSteamLogin: (steamId: string) => Promise<void>;
shareAccountWithUser: (steamId: string, targetSteamId: string) => Promise<any>;
// Server Methods
updateServerConfig: (config: Partial<ServerConfig>) => Promise<void>;
loginToServer: () => Promise<void>;
syncNow: () => Promise<void>;
getCommunityAccounts: () => Promise<any[]>;
getServerUsers: () => Promise<any[]>;
refreshAccounts: (showLoading?: boolean) => 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 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 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 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();
};
return (
<AccountsContext.Provider value={{
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
switchAccount, openSteamLogin, updateServerConfig, loginToServer,
getCommunityAccounts, getServerUsers, shareAccountWithUser, syncNow, refreshAccounts
}}>
{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

@@ -0,0 +1,77 @@
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;
};

22
frontend/src/index.css Normal file
View File

@@ -0,0 +1,22 @@
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;
}

65
frontend/src/main.tsx Normal file
View File

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

@@ -0,0 +1,553 @@
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,
Tabs, Tab, Select, MenuItem, FormControl, InputLabel
} 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 PublicIcon from '@mui/icons-material/Public';
import ShieldIcon from '@mui/icons-material/Shield';
import GppBadIcon from '@mui/icons-material/GppBad';
import PeopleIcon from '@mui/icons-material/People';
import { useAccounts, type Account } from '../hooks/useAccounts';
import { useAppTheme } from '../theme/ThemeContext';
import type { ThemeType } from '../theme/SteamTheme';
import NebulaBanner from '../components/NebulaBanner';
const Dashboard: React.FC = () => {
const { currentTheme, setTheme } = useAppTheme();
const {
accounts, isLoading, isSyncing, serverConfig, addAccount, deleteAccount,
switchAccount, openSteamLogin, updateServerConfig, loginToServer,
getCommunityAccounts, syncNow
} = useAccounts();
const [searchTerm, setSearchTerm] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [identifier, setIdentifier] = useState('');
const [addTab, setAddTab] = useState(0);
const [communityAccounts, setCommunityAccounts] = useState<any[]>([]);
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
const [serverUrl, setServerUrl] = useState('');
useEffect(() => {
if (serverConfig?.url) {
setServerUrl(serverConfig.url);
}
}, [serverConfig?.url]);
const loadCommunity = async () => {
setIsCommunityLoading(true);
try {
const data = await getCommunityAccounts();
setCommunityAccounts(Array.isArray(data) ? data : []);
} catch (e) {
} finally {
setIsCommunityLoading(false);
}
};
useEffect(() => {
if (isAddDialogOpen && addTab === 1) {
loadCommunity();
}
}, [isAddDialogOpen, addTab]);
const handleAddAccount = async () => {
if (!identifier) return;
try {
await addAccount({ identifier });
setIsAddDialogOpen(false);
setIdentifier('');
} catch (e) {
console.error("[Dashboard] Add failed:", e);
}
};
const handleAddFromCommunity = async (commAcc: any) => {
try {
await addAccount({ identifier: commAcc.steamId });
setIsAddDialogOpen(false);
} catch (e) { }
};
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}>
<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={() => setIsAddDialogOpen(true)}
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 Account" 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>
{/* Add Account Dialog */}
<Dialog open={isAddDialogOpen} onClose={() => setIsAddDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary', p: 0 }}>
<Tabs value={addTab} onChange={(_, v) => setAddTab(v)} variant="fullWidth" textColor="inherit" indicatorColor="primary">
<Tab label="Manual Add" icon={<AddIcon />} iconPosition="start" />
<Tab label="From Community" icon={<PublicIcon />} iconPosition="start" disabled={!serverConfig?.token} />
</Tabs>
</DialogTitle>
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2, minHeight: 300 }}>
{addTab === 0 ? (
<>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Enter a SteamID64 or Profile URL. You will need to authenticate to enable full tracking and instant login features.
</Typography>
<TextField
fullWidth
autoFocus
placeholder="SteamID64 or Profile URL"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { backgroundColor: 'rgba(0, 0, 0, 0.1)' } }}
/>
</>
) : (
<Box>
{isCommunityLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress size={32} /></Box>
) : (
<List>
{communityAccounts
.filter(ca => !safeAccounts.find(a => a.steamId === ca.steamId))
.map((ca) => (
<ListItem key={ca.steamId} divider sx={{ borderColor: 'divider' }}>
<Avatar src={ca.avatar} variant="square" sx={{ width: 32, height: 32, mr: 2 }} />
<ListItemText
primary={ca.personaName}
secondary={ca.steamId}
primaryTypographyProps={{ sx: { color: 'text.primary', fontWeight: 'bold' } }}
/>
<ListItemSecondaryAction>
<Button size="small" variant="contained" onClick={() => handleAddFromCommunity(ca)}>Add</Button>
</ListItemSecondaryAction>
</ListItem>
))}
{communityAccounts.length === 0 && <Typography align="center" color="textSecondary" sx={{ p: 4 }}>No shared accounts found on server.</Typography>}
</List>
)}
</Box>
)}
</DialogContent>
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}>
<Button onClick={() => setIsAddDialogOpen(false)} color="inherit">Cancel</Button>
{addTab === 0 && <Button onClick={handleAddAccount} variant="contained" color="success" disabled={!identifier}>Add</Button>}
</DialogActions>
</Dialog>
</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, 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);
alert(`Account shared successfully!`);
setIsShareOpen(false);
setTargetUserId('');
} catch (e: any) {
alert(e.message || "Failed to share account");
} finally {
setIsSharing(false);
}
};
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 }}>
{account?.steamLoginSecure ? (
<Button
variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')}
sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 } }}
>
LOGIN
</Button>
) : (
<Button variant="outlined" size="small" onClick={onAuth} sx={{ height: 28, fontSize: '0.7rem' }}>AUTH</Button>
)}
<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>
{/* Share Dialog */}
<Dialog open={isShareOpen} onClose={() => setIsShareOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Share Account</DialogTitle>
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2 }}>
<Typography variant="body2" sx={{ mb: 2 }}>
Select a community member to share this account with.
</Typography>
<FormControl fullWidth size="small" sx={{ mt: 1 }}>
<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.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 users found on server</MenuItem>}
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}>
<Button onClick={() => setIsShareOpen(false)} color="inherit" disabled={isSharing}>Cancel</Button>
<Button
onClick={handleShare}
variant="contained"
startIcon={isSharing ? <CircularProgress size={16} color="inherit" /> : <GroupAddIcon />}
disabled={!targetUserId || isSharing}
>
{isSharing ? "Sharing..." : "Grant Access"}
</Button>
</DialogActions>
</Dialog>
</TableCell>
</TableRow>
);
};
export default Dashboard;

View File

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

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

@@ -0,0 +1,52 @@
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 });
}
};
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

@@ -0,0 +1,28 @@
{
"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"]
}

7
frontend/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,26 @@
{
"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"]
}

19
frontend/vite.config.mts Normal file
View File

@@ -0,0 +1,19 @@
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'],
}
}
}
}
})