This commit is contained in:
57
.gitea/workflows/release.yml
Normal file
57
.gitea/workflows/release.yml
Normal 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
48
.gitignore
vendored
Normal 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
25
LICENSE
Normal 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
47
README.md
Normal 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
24
frontend/.gitignore
vendored
Normal 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?
|
||||
4
frontend/assets-build/icon.svg
Normal file
4
frontend/assets-build/icon.svg
Normal 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 |
563
frontend/dist-electron/main.js
Normal file
563
frontend/dist-electron/main.js
Normal 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(); });
|
||||
1
frontend/dist-electron/main.js.map
Normal file
1
frontend/dist-electron/main.js.map
Normal file
File diff suppressed because one or more lines are too long
27
frontend/dist-electron/preload.js
Normal file
27
frontend/dist-electron/preload.js
Normal 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
|
||||
});
|
||||
1
frontend/dist-electron/preload.js.map
Normal file
1
frontend/dist-electron/preload.js.map
Normal 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"}
|
||||
104
frontend/dist-electron/services/backend.js
Normal file
104
frontend/dist-electron/services/backend.js
Normal 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;
|
||||
91
frontend/dist-electron/services/scraper.js
Normal file
91
frontend/dist-electron/services/scraper.js
Normal 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;
|
||||
1
frontend/dist-electron/services/scraper.js.map
Normal file
1
frontend/dist-electron/services/scraper.js.map
Normal 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"}
|
||||
239
frontend/dist-electron/services/steam-client.js
Normal file
239
frontend/dist-electron/services/steam-client.js
Normal 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();
|
||||
111
frontend/dist-electron/services/steam-web.js
Normal file
111
frontend/dist-electron/services/steam-web.js
Normal 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;
|
||||
47
frontend/dist-electron/services/steam.js
Normal file
47
frontend/dist-electron/services/steam.js
Normal 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;
|
||||
1
frontend/dist-electron/services/steam.js.map
Normal file
1
frontend/dist-electron/services/steam.js.map
Normal 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
600
frontend/electron/main.ts
Normal 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(); });
|
||||
28
frontend/electron/preload.ts
Normal file
28
frontend/electron/preload.ts
Normal 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
|
||||
});
|
||||
94
frontend/electron/services/backend.ts
Normal file
94
frontend/electron/services/backend.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
67
frontend/electron/services/scraper.ts
Normal file
67
frontend/electron/services/scraper.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
251
frontend/electron/services/steam-client.ts
Normal file
251
frontend/electron/services/steam-client.ts
Normal 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();
|
||||
88
frontend/electron/services/steam-web.ts
Normal file
88
frontend/electron/services/steam-web.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
16
frontend/electron/tsconfig.json
Normal file
16
frontend/electron/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
4
frontend/electron/types/simple-vdf.d.ts
vendored
Normal file
4
frontend/electron/types/simple-vdf.d.ts
vendored
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
9541
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
frontend/package.json
Normal file
108
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
13
frontend/src/App.tsx
Normal 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;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
18
frontend/src/components/NebulaBanner.tsx
Normal file
18
frontend/src/components/NebulaBanner.tsx
Normal 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;
|
||||
198
frontend/src/components/SteamCard.tsx
Normal file
198
frontend/src/components/SteamCard.tsx
Normal 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;
|
||||
175
frontend/src/hooks/useAccounts.tsx
Normal file
175
frontend/src/hooks/useAccounts.tsx
Normal 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;
|
||||
};
|
||||
77
frontend/src/hooks/useAuth.tsx
Normal file
77
frontend/src/hooks/useAuth.tsx
Normal 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
22
frontend/src/index.css
Normal 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
65
frontend/src/main.tsx
Normal 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.");
|
||||
}
|
||||
553
frontend/src/pages/Dashboard.tsx
Normal file
553
frontend/src/pages/Dashboard.tsx
Normal 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;
|
||||
84
frontend/src/pages/LoginPage.tsx
Normal file
84
frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||
90
frontend/src/theme/SteamTheme.ts
Normal file
90
frontend/src/theme/SteamTheme.ts
Normal 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]);
|
||||
52
frontend/src/theme/ThemeContext.tsx
Normal file
52
frontend/src/theme/ThemeContext.tsx
Normal 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;
|
||||
};
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
19
frontend/vite.config.mts
Normal 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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user