Compare commits
30 Commits
v1.1.0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4fb03104 | |||
| d6d87107f5 | |||
| 2ef8dd06e7 | |||
| 559c7bfdef | |||
| 4ad4e1c9de | |||
| 3f7c325604 | |||
| 776e05fb52 | |||
| fc19f66ace | |||
| b64ddafab9 | |||
| 9174bcfca2 | |||
| d30005acbd | |||
| a5cc155ffc | |||
| 276d3bd4de | |||
| 34a71de2dc | |||
| 83dbfce8b2 | |||
| c208ecea95 | |||
| cf78e3c329 | |||
| fc3382c91e | |||
| ee44de182c | |||
| 6dc940bb3a | |||
| fa29bd5a85 | |||
| 5812888bb7 | |||
| 9d5f77dc09 | |||
| 75accbe5b6 | |||
| 2719bd527a | |||
| d68f0a2740 | |||
| e16a537621 | |||
| 6f66f33a9b | |||
| f0740997d0 | |||
| 1f5d2e08e5 |
@@ -1,4 +1,33 @@
|
||||
<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"/>
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1B2838"/>
|
||||
<stop offset="100%" stop-color="#101419"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#66C0F4"/>
|
||||
<stop offset="100%" stop-color="#1A9FFF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Outer Rounded Container -->
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<rect x="2" y="2" width="508" height="508" rx="98" stroke="white" stroke-opacity="0.05" stroke-width="4"/>
|
||||
|
||||
<!-- Tracking Ring (Detailed) -->
|
||||
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
|
||||
<!-- Central Shield Symbol -->
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
|
||||
<!-- "Ban" Intersect (Stylized Cross) -->
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
|
||||
<!-- Glass Highlight -->
|
||||
<path d="M100 100C150 60 362 60 412 100" stroke="white" stroke-opacity="0.1" stroke-width="20" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 1.7 KiB |
21
frontend/assets-build/icons/latte.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#EFF1F5"/>
|
||||
<stop offset="100%" stop-color="#DCE0E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1E66F5"/>
|
||||
<stop offset="100%" stop-color="#179299"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<circle cx="256" cy="256" r="180" stroke="#1E66F5" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#EFF1F5" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
21
frontend/assets-build/icons/mocha.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1E1E2E"/>
|
||||
<stop offset="100%" stop-color="#11111B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#B4BEFE"/>
|
||||
<stop offset="100%" stop-color="#89B4FA"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<circle cx="256" cy="256" r="180" stroke="#B4BEFE" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#1E1E2E" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
21
frontend/assets-build/icons/nord.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#3B4252"/>
|
||||
<stop offset="100%" stop-color="#2E3440"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#88C0D0"/>
|
||||
<stop offset="100%" stop-color="#81A1C1"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<circle cx="256" cy="256" r="180" stroke="#88C0D0" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#3B4252" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
21
frontend/assets-build/icons/steam.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1B2838"/>
|
||||
<stop offset="100%" stop-color="#101419"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#66C0F4"/>
|
||||
<stop offset="100%" stop-color="#1A9FFF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<circle cx="256" cy="256" r="180" stroke="#66C0F4" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#1B2838" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
21
frontend/assets-build/icons/tokyo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg_grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1A1B26"/>
|
||||
<stop offset="100%" stop-color="#10101A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="symbol_grad" x1="256" y1="100" x2="256" y2="412" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7AA2F7"/>
|
||||
<stop offset="100%" stop-color="#3D59A1"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="100" fill="url(#bg_grad)"/>
|
||||
<circle cx="256" cy="256" r="180" stroke="#7AA2F7" stroke-width="8" stroke-dasharray="20 10" opacity="0.2"/>
|
||||
<circle cx="256" cy="256" r="160" stroke="url(#symbol_grad)" stroke-width="20" stroke-linecap="round" stroke-dasharray="350 500" filter="url(#glow)"/>
|
||||
<path d="M256 120L360 160V260C360 330 310 380 256 400C202 380 152 330 152 260V160L256 120Z" fill="url(#symbol_grad)" filter="url(#glow)"/>
|
||||
<path d="M210 220L302 312M302 220L210 312" stroke="#1A1B26" stroke-width="24" stroke-linecap="round" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -60,9 +60,23 @@ const initBackend = () => {
|
||||
};
|
||||
// --- System Tray ---
|
||||
const createTray = () => {
|
||||
const assetsDir = path_1.default.join(__dirname, '..', 'assets-build');
|
||||
const possibleIcons = ['icon.svg', 'icon.png'];
|
||||
// Try to find the icon in various standard locations
|
||||
const possiblePaths = [
|
||||
path_1.default.join(__dirname, '..', 'assets-build'), // Dev
|
||||
path_1.default.join(process.resourcesPath, 'assets-build'), // Packaged (External)
|
||||
path_1.default.join(electron_1.app.getAppPath(), 'dist', 'assets-build'), // Packaged (Internal dist)
|
||||
path_1.default.join(electron_1.app.getAppPath(), 'assets-build') // Packaged (Internal root)
|
||||
];
|
||||
let assetsDir = '';
|
||||
for (const p of possiblePaths) {
|
||||
if (fs_1.default.existsSync(p)) {
|
||||
assetsDir = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const possibleIcons = ['icon.png', 'icon.svg'];
|
||||
let iconPath = '';
|
||||
if (assetsDir) {
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path_1.default.join(assetsDir, name);
|
||||
if (fs_1.default.existsSync(fullPath)) {
|
||||
@@ -70,27 +84,31 @@ const createTray = () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
|
||||
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
|
||||
if (!iconPath) {
|
||||
console.warn(`[Tray] FAILED: No valid icon found in ${assetsDir}`);
|
||||
console.warn(`[Tray] FAILED: No valid icon found in searched paths.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const icon = electron_1.nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
|
||||
tray = new electron_1.Tray(icon);
|
||||
tray.setToolTip('Ultimate Ban Tracker');
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
tray.on('click', () => { if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
} });
|
||||
// Load initial themed icon
|
||||
const config = store.get('serverConfig');
|
||||
if (config?.theme) {
|
||||
setAppIcon(config.theme);
|
||||
}
|
||||
});
|
||||
updateTrayMenu();
|
||||
console.log(`[Tray] Successfully initialized`);
|
||||
else {
|
||||
updateTrayMenu(); // Fallback to refresh menu
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Tray] Critical error during initialization: ${e.message}`);
|
||||
}
|
||||
catch (e) { }
|
||||
};
|
||||
const updateTrayMenu = () => {
|
||||
if (!tray)
|
||||
@@ -108,11 +126,7 @@ const updateTrayMenu = () => {
|
||||
click: () => handleSwitchAccount(acc.loginName)
|
||||
})) : [{ label: 'No accounts found', enabled: false }]
|
||||
},
|
||||
{
|
||||
label: 'Sync Now',
|
||||
enabled: !!config?.enabled,
|
||||
click: () => syncAccounts()
|
||||
},
|
||||
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Dashboard', click: () => { if (mainWindow)
|
||||
mainWindow.show(); } },
|
||||
@@ -120,6 +134,21 @@ const updateTrayMenu = () => {
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
const setAppIcon = (themeName = 'steam') => {
|
||||
const assetsDir = path_1.default.join(__dirname, '..', 'assets-build', 'icons');
|
||||
const iconPath = path_1.default.join(assetsDir, `${themeName}.svg`);
|
||||
if (!fs_1.default.existsSync(iconPath))
|
||||
return;
|
||||
const icon = electron_1.nativeImage.createFromPath(iconPath);
|
||||
// Update Tray
|
||||
if (tray) {
|
||||
tray.setImage(icon.resize({ width: 16, height: 16 }));
|
||||
}
|
||||
// Update Main Window
|
||||
if (mainWindow) {
|
||||
mainWindow.setIcon(icon);
|
||||
}
|
||||
};
|
||||
// --- Steam Logic ---
|
||||
const killSteam = async () => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -155,67 +184,10 @@ const handleSwitchAccount = async (loginName) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// --- Sync Worker ---
|
||||
const syncAccounts = async () => {
|
||||
initBackend();
|
||||
let accounts = store.get('accounts');
|
||||
let hasChanges = false;
|
||||
if (backend) {
|
||||
try {
|
||||
const shared = await backend.getSharedAccounts();
|
||||
for (const s of shared) {
|
||||
const exists = accounts.find(a => a.steamId === s.steamId);
|
||||
if (!exists) {
|
||||
accounts.push({
|
||||
_id: `shared_${s.steamId}`,
|
||||
steamId: s.steamId, personaName: s.personaName, avatar: s.avatar, profileUrl: s.profileUrl,
|
||||
vacBanned: s.vacBanned, gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
|
||||
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginConfig: s.loginConfig,
|
||||
sessionUpdatedAt: s.sessionUpdatedAt, autoCheckCooldown: !!s.steamLoginSecure,
|
||||
status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
|
||||
});
|
||||
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) {
|
||||
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) { }
|
||||
}
|
||||
if (hasChanges) {
|
||||
store.set('accounts', accounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', accounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
if (accounts.length === 0)
|
||||
return;
|
||||
const updatedAccounts = [...accounts];
|
||||
let scrapeChanges = false;
|
||||
for (const account of updatedAccounts) {
|
||||
try {
|
||||
// --- Scraper Helper ---
|
||||
const scrapeAccountData = async (account) => {
|
||||
const now = new Date();
|
||||
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
|
||||
if ((now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName) {
|
||||
try {
|
||||
const profile = await (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
|
||||
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
|
||||
account.personaName = profile.personaName;
|
||||
@@ -230,59 +202,162 @@ const syncAccounts = async () => {
|
||||
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);
|
||||
if ((now.getTime() - lastScrape.getTime()) / 3600000 > 8) {
|
||||
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 60000) + 5000));
|
||||
if (account.steamLoginSecure) {
|
||||
try {
|
||||
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
|
||||
account.authError = false;
|
||||
account.lastScrapeTime = new Date().toISOString();
|
||||
account.lastScrapeTime = now.toISOString();
|
||||
if (result.isActive) {
|
||||
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
|
||||
if (backend)
|
||||
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt);
|
||||
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
|
||||
}
|
||||
else if (account.cooldownExpiresAt) {
|
||||
else {
|
||||
account.cooldownExpiresAt = undefined;
|
||||
if (backend)
|
||||
await backend.pushCooldown(account.steamId, undefined);
|
||||
await backend.pushCooldown(account.steamId, undefined, now.toISOString());
|
||||
}
|
||||
scrapeChanges = true;
|
||||
}
|
||||
catch (e) {
|
||||
if (e.message.includes('cookie') || e.message.includes('Sign In')) {
|
||||
if (e instanceof scraper_1.SteamAuthError) {
|
||||
account.authError = true;
|
||||
}
|
||||
else {
|
||||
console.error(`[Scraper] Temporary error for ${account.personaName}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backend && !account._id.startsWith('shared_')) {
|
||||
await backend.shareAccount(account);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Scraper] Failed to scrape ${account.personaName}:`, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// --- Sync Worker ---
|
||||
const syncAccounts = async (isManual = false) => {
|
||||
console.log(`[Sync] Phase 1: Pulling from server...`);
|
||||
initBackend();
|
||||
let accounts = store.get('accounts');
|
||||
let hasChanges = false;
|
||||
if (backend) {
|
||||
try {
|
||||
const shared = await backend.getSharedAccounts();
|
||||
for (const s of shared) {
|
||||
const exists = accounts.find(a => a.steamId === s.steamId);
|
||||
if (!exists) {
|
||||
accounts.push({
|
||||
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName,
|
||||
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned,
|
||||
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
|
||||
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure,
|
||||
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt,
|
||||
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
|
||||
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
|
||||
});
|
||||
hasChanges = true;
|
||||
}
|
||||
else {
|
||||
const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
|
||||
const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
|
||||
// 1. SENSITIVE DATA SYNC (Credentials)
|
||||
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
|
||||
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
|
||||
const isLocalAccount = !exists._id.startsWith('shared_');
|
||||
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
|
||||
// SMART OVERWRITE LOGIC:
|
||||
// - If it's a remote shared account: Newest wins.
|
||||
// - If it's a LOCAL account: Only overwrite if our local session is broken/missing.
|
||||
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
|
||||
if (shouldOverwriteCredentials) {
|
||||
if (s.loginName)
|
||||
exists.loginName = s.loginName;
|
||||
if (s.loginConfig)
|
||||
exists.loginConfig = s.loginConfig;
|
||||
if (s.steamLoginSecure) {
|
||||
exists.steamLoginSecure = s.steamLoginSecure;
|
||||
exists.autoCheckCooldown = true;
|
||||
exists.authError = false;
|
||||
}
|
||||
exists.sessionUpdatedAt = s.sessionUpdatedAt;
|
||||
hasChanges = true;
|
||||
}
|
||||
// 2. Metadata Sync (Pull) - Always "Newest Wins"
|
||||
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0);
|
||||
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
|
||||
if (sMetaDate > lMetaDate) {
|
||||
exists.personaName = s.personaName;
|
||||
exists.avatar = s.avatar;
|
||||
exists.vacBanned = s.vacBanned;
|
||||
exists.gameBans = s.gameBans;
|
||||
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
|
||||
exists.lastBanCheck = s.lastMetadataCheck;
|
||||
hasChanges = true;
|
||||
}
|
||||
// Cooldown Sync (Pull)
|
||||
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
|
||||
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
|
||||
if (sScrapeDate > lScrapeDate) {
|
||||
exists.cooldownExpiresAt = s.cooldownExpiresAt;
|
||||
exists.lastScrapeTime = s.lastScrapeTime;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) {
|
||||
exists.sharedWith = s.sharedWith;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
if (hasChanges) {
|
||||
store.set('accounts', accounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', accounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
// Phase 2: Background Scrapes
|
||||
const runScrapes = async () => {
|
||||
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
|
||||
const currentAccounts = [...store.get('accounts')];
|
||||
let scrapeChanges = false;
|
||||
for (const account of currentAccounts) {
|
||||
try {
|
||||
const now = new Date();
|
||||
if (backend && !account._id.startsWith('shared_'))
|
||||
await backend.shareAccount(account);
|
||||
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
|
||||
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
|
||||
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
|
||||
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8;
|
||||
if (needsMetadata || needsCooldown || isManual) {
|
||||
if (!isManual && needsCooldown)
|
||||
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000));
|
||||
if (await scrapeAccountData(account))
|
||||
scrapeChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) { }
|
||||
}
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', updatedAccounts);
|
||||
store.set('accounts', currentAccounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', updatedAccounts);
|
||||
mainWindow.webContents.send('accounts-updated', currentAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
console.log('[Sync] Sync cycle finished.');
|
||||
};
|
||||
if (isManual)
|
||||
await runScrapes();
|
||||
else
|
||||
runScrapes();
|
||||
};
|
||||
const scheduleNextSync = () => {
|
||||
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
|
||||
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
|
||||
};
|
||||
// --- Discovery ---
|
||||
const addingAccounts = new Set();
|
||||
@@ -305,11 +380,20 @@ const handleLocalAccountsFound = async (localAccounts) => {
|
||||
const profile = await (0, steam_web_1.fetchProfileData)(local.steamId);
|
||||
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl);
|
||||
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
|
||||
// Wait and retry snagging the config (Steam takes time to write it)
|
||||
let loginConfig = undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
loginConfig = steam_client_1.steamClient.extractAccountConfig(local.accountName);
|
||||
if (loginConfig)
|
||||
break;
|
||||
}
|
||||
currentAccounts.push({
|
||||
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
|
||||
steamId: local.steamId, personaName: profile.personaName || local.accountName,
|
||||
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
|
||||
localAvatar: localPath, profileUrl: profile.profileUrl,
|
||||
loginConfig, sessionUpdatedAt: loginConfig ? new Date().toISOString() : undefined,
|
||||
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
|
||||
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
|
||||
});
|
||||
@@ -326,7 +410,7 @@ const handleLocalAccountsFound = async (localAccounts) => {
|
||||
updateTrayMenu();
|
||||
}
|
||||
};
|
||||
// --- Main Window Creation ---
|
||||
// --- Main Window ---
|
||||
function createWindow() {
|
||||
mainWindow = new electron_1.BrowserWindow({
|
||||
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
|
||||
@@ -345,7 +429,6 @@ function createWindow() {
|
||||
else
|
||||
mainWindow.loadFile(path_1.default.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
// --- App Lifecycle ---
|
||||
electron_1.app.whenReady().then(() => {
|
||||
electron_1.protocol.handle('steam-resource', (request) => {
|
||||
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
|
||||
@@ -364,7 +447,7 @@ electron_1.app.whenReady().then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
initBackend();
|
||||
setTimeout(syncAccounts, 5000);
|
||||
setTimeout(() => syncAccounts(false), 5000);
|
||||
scheduleNextSync();
|
||||
steam_client_1.steamClient.startWatching(handleLocalAccountsFound);
|
||||
});
|
||||
@@ -391,7 +474,7 @@ electron_1.ipcMain.handle('login-to-server', async () => {
|
||||
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',
|
||||
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server',
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true }
|
||||
});
|
||||
authWindow.loadURL(`${config.url}/auth/steam`);
|
||||
@@ -401,13 +484,15 @@ electron_1.ipcMain.handle('login-to-server', async () => {
|
||||
return;
|
||||
captured = true;
|
||||
let serverSteamId = undefined;
|
||||
let isAdmin = false;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
||||
serverSteamId = payload.steamId;
|
||||
isAdmin = !!payload.isAdmin;
|
||||
}
|
||||
catch (e) { }
|
||||
const current = store.get('serverConfig');
|
||||
store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
|
||||
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
|
||||
initBackend();
|
||||
authWindow.close();
|
||||
resolve(true);
|
||||
@@ -431,7 +516,21 @@ electron_1.ipcMain.handle('login-to-server', async () => {
|
||||
});
|
||||
});
|
||||
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('sync-now', async () => { await syncAccounts(true); return true; });
|
||||
electron_1.ipcMain.handle('scrape-account', async (event, steamId) => {
|
||||
const accounts = store.get('accounts');
|
||||
const account = accounts.find(a => a.steamId === steamId);
|
||||
if (!account)
|
||||
return false;
|
||||
const success = await scrapeAccountData(account);
|
||||
if (success) {
|
||||
store.set('accounts', accounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', accounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
return success;
|
||||
});
|
||||
electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
|
||||
try {
|
||||
initBackend();
|
||||
@@ -449,7 +548,7 @@ electron_1.ipcMain.handle('add-account', async (event, { identifier }) => {
|
||||
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
|
||||
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
|
||||
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
|
||||
lastBanCheck: new Date().toISOString()
|
||||
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith
|
||||
};
|
||||
store.set('accounts', [...accounts, newAccount]);
|
||||
updateTrayMenu();
|
||||
@@ -502,17 +601,112 @@ electron_1.ipcMain.handle('share-account-with-user', async (event, steamId, targ
|
||||
}
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
electron_1.ipcMain.handle('revoke-account-access', async (event, steamId, targetSteamId) => {
|
||||
initBackend();
|
||||
if (backend)
|
||||
return await backend.revokeAccess(steamId, targetSteamId);
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
electron_1.ipcMain.handle('revoke-all-account-access', async (event, steamId) => {
|
||||
initBackend();
|
||||
if (backend)
|
||||
return await backend.revokeAllAccess(steamId);
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
electron_1.ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
|
||||
electron_1.ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
|
||||
electron_1.ipcMain.handle('switch-account', async (event, loginName) => await handleSwitchAccount(loginName));
|
||||
// --- Admin IPC ---
|
||||
electron_1.ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; });
|
||||
electron_1.ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
|
||||
electron_1.ipcMain.handle('admin-delete-user', async (event, userId) => { initBackend(); if (backend)
|
||||
await backend.deleteUser(userId); return true; });
|
||||
electron_1.ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
|
||||
electron_1.ipcMain.handle('admin-remove-account', async (event, steamId) => { initBackend(); if (backend)
|
||||
await backend.forceRemoveAccount(steamId); return true; });
|
||||
electron_1.ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
|
||||
electron_1.ipcMain.handle('update-app-icon', (event, themeName) => {
|
||||
setAppIcon(themeName);
|
||||
return true;
|
||||
});
|
||||
electron_1.ipcMain.handle('switch-account', async (event, loginName) => {
|
||||
if (!loginName)
|
||||
return false;
|
||||
try {
|
||||
// PROACTIVE SYNC: Try to snag the freshest token before we kill Steam
|
||||
const accounts = store.get('accounts');
|
||||
const account = accounts.find(a => a.loginName === loginName);
|
||||
if (account && !account._id.startsWith('shared_')) {
|
||||
const freshConfig = steam_client_1.steamClient.extractAccountConfig(loginName);
|
||||
if (freshConfig) {
|
||||
account.loginConfig = freshConfig;
|
||||
account.sessionUpdatedAt = new Date().toISOString();
|
||||
if (backend)
|
||||
await backend.shareAccount(account);
|
||||
store.set('accounts', accounts);
|
||||
}
|
||||
}
|
||||
await killSteam();
|
||||
if (process.platform === 'win32') {
|
||||
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
|
||||
const commands = [
|
||||
`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,
|
||||
`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`
|
||||
];
|
||||
await new Promise((res, rej) => (0, child_process_1.exec)(commands.join(' && '), (e) => e ? rej(e) : res()));
|
||||
if (account && account.loginConfig)
|
||||
steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig);
|
||||
}
|
||||
else if (process.platform === 'linux') {
|
||||
await steam_client_1.steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
|
||||
}
|
||||
startSteam();
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url));
|
||||
electron_1.ipcMain.handle('open-steam-app-login', async () => {
|
||||
await killSteam();
|
||||
if (process.platform === 'win32') {
|
||||
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
|
||||
await new Promise((res) => (0, child_process_1.exec)(clearReg, () => res()));
|
||||
}
|
||||
else if (process.platform === 'linux') {
|
||||
await steam_client_1.steamClient.setAutoLoginUser("", undefined, "");
|
||||
}
|
||||
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
|
||||
(0, child_process_1.exec)(command);
|
||||
return true;
|
||||
});
|
||||
electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => {
|
||||
const loginSession = electron_1.session.fromPartition('persist:steam-login');
|
||||
// Removed: automatic clearStorageData to allow cookie persistence
|
||||
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
|
||||
const loginSession = electron_1.session.fromPartition(partitionId);
|
||||
if (!expectedSteamId)
|
||||
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
|
||||
if (expectedSteamId) {
|
||||
const accounts = store.get('accounts');
|
||||
const account = accounts.find(a => a.steamId === expectedSteamId);
|
||||
if (account?.steamLoginSecure) {
|
||||
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
|
||||
for (const pair of cookiePairs) {
|
||||
const [name, value] = pair.split('=');
|
||||
if (name && value) {
|
||||
try {
|
||||
await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') });
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const loginWindow = new electron_1.BrowserWindow({
|
||||
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: 'persist:steam-login' }
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
|
||||
});
|
||||
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
|
||||
const checkCookie = setInterval(async () => {
|
||||
|
||||
@@ -8,7 +8,11 @@ electron_1.contextBridge.exposeInMainWorld('electronAPI', {
|
||||
deleteAccount: (id) => electron_1.ipcRenderer.invoke('delete-account', id),
|
||||
switchAccount: (loginName) => electron_1.ipcRenderer.invoke('switch-account', loginName),
|
||||
shareAccountWithUser: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('share-account-with-user', steamId, targetSteamId),
|
||||
revokeAccountAccess: (steamId, targetSteamId) => electron_1.ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
|
||||
revokeAllAccountAccess: (steamId) => electron_1.ipcRenderer.invoke('revoke-all-account-access', steamId),
|
||||
openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', url),
|
||||
updateAppIcon: (theme) => electron_1.ipcRenderer.invoke('update-app-icon', theme),
|
||||
openSteamAppLogin: () => electron_1.ipcRenderer.invoke('open-steam-app-login'),
|
||||
openSteamLogin: (steamId) => electron_1.ipcRenderer.invoke('open-steam-login', steamId),
|
||||
// Server Config & Auth
|
||||
getServerConfig: () => electron_1.ipcRenderer.invoke('get-server-config'),
|
||||
@@ -16,8 +20,15 @@ electron_1.contextBridge.exposeInMainWorld('electronAPI', {
|
||||
loginToServer: () => electron_1.ipcRenderer.invoke('login-to-server'),
|
||||
getServerUserInfo: () => electron_1.ipcRenderer.invoke('get-server-user-info'),
|
||||
syncNow: () => electron_1.ipcRenderer.invoke('sync-now'),
|
||||
scrapeAccount: (steamId) => electron_1.ipcRenderer.invoke('scrape-account', steamId),
|
||||
getCommunityAccounts: () => electron_1.ipcRenderer.invoke('get-community-accounts'),
|
||||
getServerUsers: () => electron_1.ipcRenderer.invoke('get-server-users'),
|
||||
// Admin API
|
||||
adminGetStats: () => electron_1.ipcRenderer.invoke('admin-get-stats'),
|
||||
adminGetUsers: () => electron_1.ipcRenderer.invoke('admin-get-users'),
|
||||
adminDeleteUser: (userId) => electron_1.ipcRenderer.invoke('admin-delete-user', userId),
|
||||
adminGetAccounts: () => electron_1.ipcRenderer.invoke('admin-get-accounts'),
|
||||
adminRemoveAccount: (steamId) => electron_1.ipcRenderer.invoke('admin-remove-account', steamId),
|
||||
onAccountsUpdated: (callback) => {
|
||||
const subscription = (_event, accounts) => callback(accounts);
|
||||
electron_1.ipcRenderer.on('accounts-updated', subscription);
|
||||
|
||||
@@ -67,19 +67,24 @@ class BackendService {
|
||||
gameBans: account.gameBans,
|
||||
loginName: account.loginName,
|
||||
steamLoginSecure: account.steamLoginSecure,
|
||||
loginConfig: account.loginConfig
|
||||
loginConfig: account.loginConfig,
|
||||
sessionUpdatedAt: account.sessionUpdatedAt,
|
||||
lastMetadataCheck: account.lastBanCheck,
|
||||
lastScrapeTime: account.lastScrapeTime,
|
||||
cooldownExpiresAt: account.cooldownExpiresAt
|
||||
}, { headers: this.headers });
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[Backend] Failed to share account');
|
||||
}
|
||||
}
|
||||
async pushCooldown(steamId, cooldownExpiresAt) {
|
||||
async pushCooldown(steamId, cooldownExpiresAt, lastScrapeTime) {
|
||||
if (!this.token)
|
||||
return;
|
||||
try {
|
||||
await axios_1.default.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
|
||||
cooldownExpiresAt
|
||||
cooldownExpiresAt,
|
||||
lastScrapeTime
|
||||
}, { headers: this.headers });
|
||||
}
|
||||
catch (e) {
|
||||
@@ -100,5 +105,88 @@ class BackendService {
|
||||
throw new Error(e.response?.data?.message || 'Failed to share account');
|
||||
}
|
||||
}
|
||||
async revokeAccess(steamId, targetSteamId) {
|
||||
if (!this.token)
|
||||
return;
|
||||
try {
|
||||
const response = await axios_1.default.delete(`${this.url}/api/sync/${steamId}/share`, {
|
||||
headers: this.headers,
|
||||
data: { targetSteamId }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Backend] Failed to revoke access for ${steamId} from ${targetSteamId}`);
|
||||
throw new Error(e.response?.data?.message || 'Failed to revoke access');
|
||||
}
|
||||
}
|
||||
async revokeAllAccess(steamId) {
|
||||
if (!this.token)
|
||||
return;
|
||||
try {
|
||||
const response = await axios_1.default.delete(`${this.url}/api/sync/${steamId}/share/all`, {
|
||||
headers: this.headers
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Backend] Failed to revoke all access for ${steamId}`);
|
||||
throw new Error(e.response?.data?.message || 'Failed to revoke all access');
|
||||
}
|
||||
}
|
||||
// --- Admin API ---
|
||||
async getAdminStats() {
|
||||
if (!this.token)
|
||||
return null;
|
||||
try {
|
||||
const response = await axios_1.default.get(`${this.url}/api/admin/stats`, { headers: this.headers });
|
||||
return response.data;
|
||||
}
|
||||
catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async getAdminUsers() {
|
||||
if (!this.token)
|
||||
return [];
|
||||
try {
|
||||
const response = await axios_1.default.get(`${this.url}/api/admin/users`, { headers: this.headers });
|
||||
return response.data;
|
||||
}
|
||||
catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async deleteUser(userId) {
|
||||
if (!this.token)
|
||||
return;
|
||||
try {
|
||||
await axios_1.default.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(e.response?.data?.message || 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
async getAdminAccounts() {
|
||||
if (!this.token)
|
||||
return [];
|
||||
try {
|
||||
const response = await axios_1.default.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
|
||||
return response.data;
|
||||
}
|
||||
catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async forceRemoveAccount(steamId) {
|
||||
if (!this.token)
|
||||
return;
|
||||
try {
|
||||
await axios_1.default.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(e.response?.data?.message || 'Failed to remove account');
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.BackendService = BackendService;
|
||||
|
||||
@@ -36,9 +36,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.scrapeCooldown = void 0;
|
||||
exports.scrapeCooldown = exports.SteamAuthError = void 0;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const cheerio = __importStar(require("cheerio"));
|
||||
// Custom error to identify session death
|
||||
class SteamAuthError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "SteamAuthError";
|
||||
}
|
||||
}
|
||||
exports.SteamAuthError = SteamAuthError;
|
||||
const scrapeCooldown = async (steamId, steamLoginSecure) => {
|
||||
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
|
||||
try {
|
||||
@@ -47,35 +55,38 @@ const scrapeCooldown = async (steamId, steamLoginSecure) => {
|
||||
'Cookie': steamLoginSecure,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
|
||||
});
|
||||
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');
|
||||
// If Steam redirects us to the login page, the cookie is dead
|
||||
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
|
||||
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
|
||||
}
|
||||
const $ = cheerio.load(response.data);
|
||||
if (!response.data.includes('Personal Game Data')) {
|
||||
throw new SteamAuthError('Session invalid: Personal Game Data not accessible');
|
||||
}
|
||||
// 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'));
|
||||
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration'));
|
||||
if (expirationIndex !== -1) {
|
||||
const firstRow = $(table).find('tr').not(':has(th)').first();
|
||||
const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
|
||||
const rows = $(table).find('tr').not(':has(th)');
|
||||
rows.each((_, row) => {
|
||||
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
|
||||
if (dateText && dateText !== '') {
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
if (!expirationDate || parsed > expirationDate)
|
||||
expirationDate = parsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (expirationDate && expirationDate.getTime() > Date.now()) {
|
||||
console.log(`[Scraper] Found active cooldown until: ${expirationDate.toISOString()}`);
|
||||
return {
|
||||
isActive: true,
|
||||
expiresAt: expirationDate
|
||||
};
|
||||
return { isActive: true, expiresAt: expirationDate };
|
||||
}
|
||||
const content = $('#personal_game_data_content').text();
|
||||
if (content.includes('Competitive Cooldown') || content.includes('Your account is currently')) {
|
||||
@@ -84,8 +95,10 @@ const scrapeCooldown = async (steamId, steamLoginSecure) => {
|
||||
return { isActive: false };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[Scraper] Error for ${steamId}:`, error.message);
|
||||
if (error instanceof SteamAuthError)
|
||||
throw error;
|
||||
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message);
|
||||
throw error; // Generic errors don't trigger re-auth
|
||||
}
|
||||
};
|
||||
exports.scrapeCooldown = scrapeCooldown;
|
||||
|
||||
@@ -21,7 +21,8 @@ class SteamClientService {
|
||||
if (platform === 'win32') {
|
||||
const possiblePaths = [
|
||||
'C:\\Program Files (x86)\\Steam',
|
||||
'C:\\Program Files\\Steam'
|
||||
'C:\\Program Files\\Steam',
|
||||
path_1.default.join(process.env.APPDATA || '', 'Steam'),
|
||||
];
|
||||
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
|
||||
}
|
||||
@@ -29,7 +30,8 @@ class SteamClientService {
|
||||
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')
|
||||
path_1.default.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak
|
||||
path_1.default.join(home, 'snap/steam/common/.steam/steam'), // Snap
|
||||
];
|
||||
this.steamPath = possiblePaths.find(p => fs_1.default.existsSync(p)) || null;
|
||||
}
|
||||
@@ -47,12 +49,29 @@ class SteamClientService {
|
||||
return null;
|
||||
return path_1.default.join(this.steamPath, 'config', 'config.vdf');
|
||||
}
|
||||
safeWriteVdf(filePath, data) {
|
||||
const tempPath = `${filePath}.tmp_${Date.now()}`;
|
||||
const dir = path_1.default.dirname(filePath);
|
||||
try {
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
fs_1.default.mkdirSync(dir, { recursive: true });
|
||||
const vdfContent = (0, simple_vdf_1.stringify)(data);
|
||||
fs_1.default.writeFileSync(tempPath, vdfContent, 'utf-8');
|
||||
fs_1.default.renameSync(tempPath, filePath);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
|
||||
if (fs_1.default.existsSync(tempPath))
|
||||
fs_1.default.unlinkSync(tempPath);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
startWatching(callback) {
|
||||
this.onAccountsChanged = callback;
|
||||
const loginUsersPath = this.getLoginUsersPath();
|
||||
if (loginUsersPath && fs_1.default.existsSync(loginUsersPath)) {
|
||||
this.readLocalAccounts();
|
||||
chokidar_1.default.watch(loginUsersPath, { persistent: true }).on('change', () => {
|
||||
chokidar_1.default.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => {
|
||||
this.readLocalAccounts();
|
||||
});
|
||||
}
|
||||
@@ -63,25 +82,26 @@ class SteamClientService {
|
||||
return;
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
||||
if (!content.trim())
|
||||
return;
|
||||
const data = (0, simple_vdf_1.parse)(content);
|
||||
if (!data || !data.users)
|
||||
return;
|
||||
const accounts = [];
|
||||
for (const [steamId64, userData] of Object.entries(data.users)) {
|
||||
const user = userData;
|
||||
if (!user || !user.AccountName)
|
||||
continue;
|
||||
accounts.push({
|
||||
steamId: steamId64,
|
||||
accountName: user.AccountName,
|
||||
personaName: user.PersonaName,
|
||||
steamId: steamId64, accountName: user.AccountName,
|
||||
personaName: user.PersonaName || user.AccountName,
|
||||
timestamp: parseInt(user.Timestamp) || 0
|
||||
});
|
||||
}
|
||||
if (this.onAccountsChanged)
|
||||
this.onAccountsChanged(accounts);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[SteamClient] Error parsing loginusers.vdf:', error);
|
||||
}
|
||||
catch (error) { }
|
||||
}
|
||||
extractAccountConfig(accountName) {
|
||||
const configPath = this.getConfigVdfPath();
|
||||
@@ -91,63 +111,60 @@ class SteamClientService {
|
||||
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];
|
||||
}
|
||||
return (accounts && accounts[accountName]) ? accounts[accountName] : null;
|
||||
}
|
||||
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);
|
||||
const parsed = (0, simple_vdf_1.parse)(content);
|
||||
if (parsed && typeof parsed === 'object')
|
||||
data = parsed;
|
||||
}
|
||||
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;
|
||||
const ensurePath = (obj, keys) => {
|
||||
let curr = obj;
|
||||
for (const key of keys) {
|
||||
if (!curr[key] || typeof curr[key] !== 'object')
|
||||
curr[key] = {};
|
||||
curr = curr[key];
|
||||
}
|
||||
return curr;
|
||||
};
|
||||
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']);
|
||||
// FAILPROOF: Force crucial flags that Steam uses to decide session validity
|
||||
steamAccounts[accountName] = {
|
||||
...accountData,
|
||||
RememberPassword: "1",
|
||||
AllowAutoLogin: "1",
|
||||
Timestamp: Math.floor(Date.now() / 1000).toString()
|
||||
};
|
||||
try {
|
||||
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');
|
||||
this.safeWriteVdf(configPath, data);
|
||||
console.log(`[SteamClient] Safely injected session for ${accountName}`);
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
async setAutoLoginUser(accountName, accountConfig, steamId) {
|
||||
const platform = os_1.default.platform();
|
||||
const loginUsersPath = this.getLoginUsersPath();
|
||||
if (loginUsersPath) {
|
||||
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);
|
||||
const parsed = (0, simple_vdf_1.parse)(content);
|
||||
if (parsed && parsed.users)
|
||||
data = parsed;
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
@@ -156,7 +173,7 @@ class SteamClientService {
|
||||
let found = false;
|
||||
for (const [id, user] of Object.entries(data.users)) {
|
||||
const u = user;
|
||||
if (u.AccountName.toLowerCase() === accountName.toLowerCase()) {
|
||||
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) {
|
||||
u.mostrecent = "1";
|
||||
u.RememberPassword = "1";
|
||||
u.AllowAutoLogin = "1";
|
||||
@@ -169,8 +186,8 @@ class SteamClientService {
|
||||
u.mostrecent = "0";
|
||||
}
|
||||
}
|
||||
if (!found && steamId) {
|
||||
console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
|
||||
if (!found && steamId && accountName) {
|
||||
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`);
|
||||
data.users[steamId] = {
|
||||
AccountName: accountName,
|
||||
PersonaName: accountName,
|
||||
@@ -184,51 +201,49 @@ class SteamClientService {
|
||||
};
|
||||
}
|
||||
try {
|
||||
fs_1.default.writeFileSync(loginUsersPath, (0, simple_vdf_1.stringify)(data));
|
||||
this.safeWriteVdf(loginUsersPath, data);
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[SteamClient] Failed to write loginusers.vdf');
|
||||
catch (e) { }
|
||||
}
|
||||
}
|
||||
if (accountConfig) {
|
||||
// Injection of the actual authentication blob
|
||||
if (accountConfig && accountName) {
|
||||
this.injectAccountConfig(accountName, accountConfig);
|
||||
}
|
||||
// --- Linux Registry / Registry.vdf Hardening ---
|
||||
if (platform === 'linux') {
|
||||
const regLocations = [
|
||||
path_1.default.join(os_1.default.homedir(), '.steam', 'registry.vdf'),
|
||||
path_1.default.join(os_1.default.homedir(), '.steam', 'steam', 'registry.vdf')
|
||||
];
|
||||
for (const regPath of regLocations) {
|
||||
let regData = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
|
||||
if (!fs_1.default.existsSync(path_1.default.dirname(regPath)))
|
||||
continue;
|
||||
let regData = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
|
||||
if (fs_1.default.existsSync(regPath)) {
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(regPath, 'utf-8');
|
||||
regData = (0, simple_vdf_1.parse)(content);
|
||||
const parsed = (0, simple_vdf_1.parse)(content);
|
||||
if (parsed && typeof parsed === 'object')
|
||||
regData = parsed;
|
||||
}
|
||||
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) => {
|
||||
const ensurePath = (obj, keys) => {
|
||||
let curr = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!curr[keys[i]])
|
||||
curr[keys[i]] = {};
|
||||
curr = curr[keys[i]];
|
||||
for (const key of keys) {
|
||||
if (!curr[key] || typeof curr[key] !== 'object')
|
||||
curr[key] = {};
|
||||
curr = curr[key];
|
||||
}
|
||||
curr[keys[keys.length - 1]] = val;
|
||||
return curr;
|
||||
};
|
||||
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");
|
||||
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']);
|
||||
steamKey.AutoLoginUser = accountName;
|
||||
steamKey.RememberPassword = "1";
|
||||
steamKey.AlreadyLoggedIn = "1";
|
||||
steamKey.WantsOfflineMode = "0";
|
||||
try {
|
||||
fs_1.default.writeFileSync(regPath, (0, simple_vdf_1.stringify)(regData));
|
||||
console.log(`[SteamClient] Registry updated: ${regPath}`);
|
||||
this.safeWriteVdf(regPath, regData);
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { scrapeCooldown, SteamAuthError } from './services/scraper';
|
||||
import { steamClient, LocalSteamAccount } from './services/steam-client';
|
||||
import { BackendService } from './services/backend';
|
||||
|
||||
@@ -40,6 +40,7 @@ interface Account {
|
||||
cooldownExpiresAt?: string;
|
||||
authError?: boolean;
|
||||
notes?: string;
|
||||
sharedWith?: any[];
|
||||
}
|
||||
|
||||
interface ServerConfig {
|
||||
@@ -48,6 +49,7 @@ interface ServerConfig {
|
||||
serverSteamId?: string;
|
||||
enabled: boolean;
|
||||
theme?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// --- App State ---
|
||||
@@ -91,10 +93,26 @@ const initBackend = () => {
|
||||
|
||||
// --- System Tray ---
|
||||
const createTray = () => {
|
||||
const assetsDir = path.join(__dirname, '..', 'assets-build');
|
||||
const possibleIcons = ['icon.svg', 'icon.png'];
|
||||
// Try to find the icon in various standard locations
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', 'assets-build'), // Dev
|
||||
path.join(process.resourcesPath, 'assets-build'), // Packaged (External)
|
||||
path.join(app.getAppPath(), 'dist', 'assets-build'), // Packaged (Internal dist)
|
||||
path.join(app.getAppPath(), 'assets-build') // Packaged (Internal root)
|
||||
];
|
||||
|
||||
let assetsDir = '';
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
assetsDir = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const possibleIcons = ['icon.png', 'icon.svg'];
|
||||
let iconPath = '';
|
||||
|
||||
if (assetsDir) {
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path.join(assetsDir, name);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
@@ -102,11 +120,13 @@ const createTray = () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
|
||||
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
|
||||
|
||||
if (!iconPath) {
|
||||
console.warn(`[Tray] FAILED: No valid icon found in ${assetsDir}`);
|
||||
console.warn(`[Tray] FAILED: No valid icon found in searched paths.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,24 +134,22 @@ const createTray = () => {
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Ultimate Ban Tracker');
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
updateTrayMenu();
|
||||
console.log(`[Tray] Successfully initialized`);
|
||||
} catch (e: any) {
|
||||
console.error(`[Tray] Critical error during initialization: ${e.message}`);
|
||||
tray.on('click', () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } });
|
||||
|
||||
// Load initial themed icon
|
||||
const config = store.get('serverConfig');
|
||||
if (config?.theme) {
|
||||
setAppIcon(config.theme);
|
||||
} else {
|
||||
updateTrayMenu(); // Fallback to refresh menu
|
||||
}
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
const updateTrayMenu = () => {
|
||||
if (!tray) return;
|
||||
const accounts = store.get('accounts') as Account[];
|
||||
const config = store.get('serverConfig');
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: `Ultimate Ban Tracker v${app.getVersion()}`, enabled: false },
|
||||
{ type: 'separator' },
|
||||
@@ -143,19 +161,33 @@ const updateTrayMenu = () => {
|
||||
click: () => handleSwitchAccount(acc.loginName)
|
||||
})) : [{ label: 'No accounts found', enabled: false }]
|
||||
},
|
||||
{
|
||||
label: 'Sync Now',
|
||||
enabled: !!config?.enabled,
|
||||
click: () => syncAccounts()
|
||||
},
|
||||
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Dashboard', click: () => { if (mainWindow) mainWindow.show(); } },
|
||||
{ label: 'Quit', click: () => { (app as any).isQuitting = true; app.quit(); } }
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const setAppIcon = (themeName: string = 'steam') => {
|
||||
const assetsDir = path.join(__dirname, '..', 'assets-build', 'icons');
|
||||
const iconPath = path.join(assetsDir, `${themeName}.svg`);
|
||||
|
||||
if (!fs.existsSync(iconPath)) return;
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// Update Tray
|
||||
if (tray) {
|
||||
tray.setImage(icon.resize({ width: 16, height: 16 }));
|
||||
}
|
||||
|
||||
// Update Main Window
|
||||
if (mainWindow) {
|
||||
mainWindow.setIcon(icon);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Steam Logic ---
|
||||
const killSteam = async () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -188,8 +220,53 @@ const handleSwitchAccount = async (loginName: string) => {
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
|
||||
// --- Scraper Helper ---
|
||||
const scrapeAccountData = async (account: Account) => {
|
||||
const now = new Date();
|
||||
try {
|
||||
const profile = await fetchProfileData(account.steamId, account.steamLoginSecure);
|
||||
const bans = await scrapeBanStatus(profile.profileUrl, account.steamLoginSecure);
|
||||
account.personaName = profile.personaName; account.profileUrl = profile.profileUrl;
|
||||
account.vacBanned = bans.vacBanned; account.gameBans = bans.gameBans;
|
||||
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
|
||||
account.lastBanCheck = now.toISOString();
|
||||
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
|
||||
account.avatar = profile.avatar;
|
||||
const localPath = await downloadAvatar(account.steamId, profile.avatar);
|
||||
if (localPath) account.localAvatar = localPath;
|
||||
}
|
||||
if (account.steamLoginSecure) {
|
||||
try {
|
||||
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
|
||||
account.authError = false; account.lastScrapeTime = now.toISOString();
|
||||
if (result.isActive) {
|
||||
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
|
||||
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
|
||||
} else {
|
||||
account.cooldownExpiresAt = undefined;
|
||||
if (backend) await backend.pushCooldown(account.steamId, undefined, now.toISOString());
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof SteamAuthError) {
|
||||
account.authError = true;
|
||||
} else {
|
||||
console.error(`[Scraper] Temporary error for ${account.personaName}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backend && !account._id.startsWith('shared_')) {
|
||||
await backend.shareAccount(account);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[Scraper] Failed to scrape ${account.personaName}:`, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Sync Worker ---
|
||||
const syncAccounts = async () => {
|
||||
const syncAccounts = async (isManual = false) => {
|
||||
console.log(`[Sync] Phase 1: Pulling from server...`);
|
||||
initBackend();
|
||||
let accounts = store.get('accounts') as Account[];
|
||||
let hasChanges = false;
|
||||
@@ -201,26 +278,66 @@ const syncAccounts = async () => {
|
||||
const exists = accounts.find(a => a.steamId === s.steamId);
|
||||
if (!exists) {
|
||||
accounts.push({
|
||||
_id: `shared_${s.steamId}`,
|
||||
steamId: s.steamId, personaName: s.personaName, avatar: s.avatar, profileUrl: s.profileUrl,
|
||||
vacBanned: s.vacBanned, gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
|
||||
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure, loginConfig: s.loginConfig,
|
||||
sessionUpdatedAt: s.sessionUpdatedAt, autoCheckCooldown: !!s.steamLoginSecure,
|
||||
status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none', lastBanCheck: new Date().toISOString()
|
||||
_id: `shared_${s.steamId}`, steamId: s.steamId, personaName: s.personaName,
|
||||
avatar: s.avatar, profileUrl: s.profileUrl, vacBanned: s.vacBanned,
|
||||
gameBans: s.gameBans, cooldownExpiresAt: s.cooldownExpiresAt,
|
||||
loginName: s.loginName || '', steamLoginSecure: s.steamLoginSecure,
|
||||
loginConfig: s.loginConfig, sessionUpdatedAt: s.sessionUpdatedAt,
|
||||
autoCheckCooldown: !!s.steamLoginSecure, status: (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none',
|
||||
lastBanCheck: new Date().toISOString(), sharedWith: s.sharedWith
|
||||
});
|
||||
hasChanges = true;
|
||||
} else {
|
||||
const sDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
|
||||
const lDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
|
||||
if (sDate > lDate) {
|
||||
// 1. SENSITIVE DATA SYNC (Credentials)
|
||||
const sSessionDate = s.sessionUpdatedAt ? new Date(s.sessionUpdatedAt) : new Date(0);
|
||||
const lSessionDate = exists.sessionUpdatedAt ? new Date(exists.sessionUpdatedAt) : new Date(0);
|
||||
|
||||
const isLocalAccount = !exists._id.startsWith('shared_');
|
||||
const isLocalSessionHealthy = exists.steamLoginSecure && !exists.authError;
|
||||
|
||||
// SMART OVERWRITE LOGIC:
|
||||
// - If it's a remote shared account: Newest wins.
|
||||
// - If it's a LOCAL account: Only overwrite if our local session is broken/missing.
|
||||
const shouldOverwriteCredentials = !isLocalAccount ? (sSessionDate > lSessionDate) : (!isLocalSessionHealthy && sSessionDate > lSessionDate);
|
||||
|
||||
if (shouldOverwriteCredentials) {
|
||||
if (s.loginName) exists.loginName = s.loginName;
|
||||
if (s.loginConfig) exists.loginConfig = s.loginConfig;
|
||||
if (s.steamLoginSecure) { exists.steamLoginSecure = s.steamLoginSecure; exists.autoCheckCooldown = true; exists.authError = false; }
|
||||
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))) {
|
||||
|
||||
// 2. Metadata Sync (Pull) - Always "Newest Wins"
|
||||
const sMetaDate = s.lastMetadataCheck ? new Date(s.lastMetadataCheck) : new Date(0);
|
||||
const lMetaDate = exists.lastBanCheck ? new Date(exists.lastBanCheck) : new Date(0);
|
||||
if (sMetaDate > lMetaDate) {
|
||||
exists.personaName = s.personaName;
|
||||
exists.avatar = s.avatar;
|
||||
exists.vacBanned = s.vacBanned;
|
||||
exists.gameBans = s.gameBans;
|
||||
exists.status = (s.vacBanned || s.gameBans > 0) ? 'banned' : 'none';
|
||||
exists.lastBanCheck = s.lastMetadataCheck;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Cooldown Sync (Pull)
|
||||
const sScrapeDate = s.lastScrapeTime ? new Date(s.lastScrapeTime) : new Date(0);
|
||||
const lScrapeDate = exists.lastScrapeTime ? new Date(exists.lastScrapeTime) : new Date(0);
|
||||
if (sScrapeDate > lScrapeDate) {
|
||||
exists.cooldownExpiresAt = s.cooldownExpiresAt;
|
||||
exists.lastScrapeTime = s.lastScrapeTime;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) {
|
||||
exists.sharedWith = s.sharedWith;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
@@ -234,68 +351,39 @@ const syncAccounts = async () => {
|
||||
updateTrayMenu();
|
||||
}
|
||||
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const updatedAccounts = [...accounts];
|
||||
// Phase 2: Background Scrapes
|
||||
const runScrapes = async () => {
|
||||
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
|
||||
const currentAccounts = [...store.get('accounts') as Account[]];
|
||||
let scrapeChanges = false;
|
||||
|
||||
for (const account of updatedAccounts) {
|
||||
for (const account of currentAccounts) {
|
||||
try {
|
||||
const now = new Date();
|
||||
if (backend && !account._id.startsWith('shared_')) await backend.shareAccount(account);
|
||||
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
|
||||
if ((now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName) {
|
||||
const profile = await fetchProfileData(account.steamId, account.steamLoginSecure);
|
||||
const bans = await scrapeBanStatus(profile.profileUrl, account.steamLoginSecure);
|
||||
account.personaName = profile.personaName; account.profileUrl = profile.profileUrl;
|
||||
account.vacBanned = bans.vacBanned; account.gameBans = bans.gameBans;
|
||||
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
|
||||
account.lastBanCheck = now.toISOString();
|
||||
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
|
||||
account.avatar = profile.avatar;
|
||||
const localPath = await downloadAvatar(account.steamId, profile.avatar);
|
||||
if (localPath) account.localAvatar = localPath;
|
||||
}
|
||||
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);
|
||||
if ((now.getTime() - lastScrape.getTime()) / 3600000 > 8) {
|
||||
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 60000) + 5000));
|
||||
try {
|
||||
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
|
||||
account.authError = false; account.lastScrapeTime = new Date().toISOString();
|
||||
if (result.isActive) {
|
||||
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
|
||||
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt);
|
||||
} else if (account.cooldownExpiresAt) {
|
||||
account.cooldownExpiresAt = undefined;
|
||||
if (backend) await backend.pushCooldown(account.steamId, undefined);
|
||||
}
|
||||
scrapeChanges = true;
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('cookie') || e.message.includes('Sign In')) { account.authError = true; scrapeChanges = true; }
|
||||
}
|
||||
}
|
||||
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
|
||||
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8;
|
||||
|
||||
if (needsMetadata || needsCooldown || isManual) {
|
||||
if (!isManual && needsCooldown) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000));
|
||||
if (await scrapeAccountData(account)) scrapeChanges = true;
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', updatedAccounts);
|
||||
if (mainWindow) mainWindow.webContents.send('accounts-updated', updatedAccounts);
|
||||
store.set('accounts', currentAccounts);
|
||||
if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
console.log('[Sync] Sync cycle finished.');
|
||||
};
|
||||
|
||||
if (isManual) await runScrapes(); else runScrapes();
|
||||
};
|
||||
|
||||
const scheduleNextSync = () => {
|
||||
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
|
||||
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
|
||||
};
|
||||
|
||||
// --- Discovery ---
|
||||
@@ -314,11 +402,21 @@ const handleLocalAccountsFound = async (localAccounts: LocalSteamAccount[]) => {
|
||||
const profile = await fetchProfileData(local.steamId);
|
||||
const bans = await scrapeBanStatus(profile.profileUrl);
|
||||
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
|
||||
|
||||
// Wait and retry snagging the config (Steam takes time to write it)
|
||||
let loginConfig = undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
loginConfig = steamClient.extractAccountConfig(local.accountName);
|
||||
if (loginConfig) break;
|
||||
}
|
||||
|
||||
currentAccounts.push({
|
||||
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
|
||||
steamId: local.steamId, personaName: profile.personaName || local.accountName,
|
||||
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
|
||||
localAvatar: localPath, profileUrl: profile.profileUrl,
|
||||
loginConfig, sessionUpdatedAt: loginConfig ? new Date().toISOString() : undefined,
|
||||
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
|
||||
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
|
||||
});
|
||||
@@ -334,28 +432,21 @@ const handleLocalAccountsFound = async (localAccounts: LocalSteamAccount[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Main Window Creation ---
|
||||
// --- Main Window ---
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
|
||||
webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true }
|
||||
});
|
||||
|
||||
mainWindow.setMenu(null);
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!(app as any).isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
if (!(app as any).isQuitting) { event.preventDefault(); mainWindow?.hide(); }
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isDev) mainWindow.loadURL('http://localhost:5173');
|
||||
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
|
||||
// --- App Lifecycle ---
|
||||
app.whenReady().then(() => {
|
||||
protocol.handle('steam-resource', (request) => {
|
||||
let rawPath = decodeURIComponent(request.url.replace('steam-resource://', ''));
|
||||
@@ -364,11 +455,10 @@ app.whenReady().then(() => {
|
||||
if (!fs.existsSync(absolutePath)) return new Response('Not Found', { status: 404 });
|
||||
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
|
||||
});
|
||||
|
||||
createWindow();
|
||||
createTray();
|
||||
initBackend();
|
||||
setTimeout(syncAccounts, 5000);
|
||||
setTimeout(() => syncAccounts(false), 5000);
|
||||
scheduleNextSync();
|
||||
steamClient.startWatching(handleLocalAccountsFound);
|
||||
});
|
||||
@@ -393,17 +483,20 @@ ipcMain.handle('login-to-server', async () => {
|
||||
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',
|
||||
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Server',
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true }
|
||||
});
|
||||
authWindow.loadURL(`${config.url}/auth/steam`);
|
||||
let captured = false;
|
||||
const saveServerAuth = (token: string) => {
|
||||
if (captured) return; captured = true;
|
||||
let serverSteamId = undefined;
|
||||
try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString()); serverSteamId = payload.steamId; } catch (e) {}
|
||||
let serverSteamId = undefined; let isAdmin = false;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString());
|
||||
serverSteamId = payload.steamId; isAdmin = !!payload.isAdmin;
|
||||
} catch (e) {}
|
||||
const current = store.get('serverConfig');
|
||||
store.set('serverConfig', { ...current, token, serverSteamId, enabled: true });
|
||||
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
|
||||
initBackend();
|
||||
authWindow.close();
|
||||
resolve(true);
|
||||
@@ -423,7 +516,21 @@ ipcMain.handle('login-to-server', async () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-server-user-info', () => ({ steamId: store.get('serverConfig').serverSteamId }));
|
||||
ipcMain.handle('sync-now', async () => { await syncAccounts(); return true; });
|
||||
ipcMain.handle('sync-now', async () => { await syncAccounts(true); return true; });
|
||||
|
||||
ipcMain.handle('scrape-account', async (event, steamId: string) => {
|
||||
const accounts = store.get('accounts') as Account[];
|
||||
const account = accounts.find(a => a.steamId === steamId);
|
||||
if (!account) return false;
|
||||
const success = await scrapeAccountData(account);
|
||||
if (success) {
|
||||
store.set('accounts', accounts);
|
||||
if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
return success;
|
||||
});
|
||||
|
||||
ipcMain.handle('add-account', async (event, { identifier }) => {
|
||||
try {
|
||||
initBackend();
|
||||
@@ -440,7 +547,7 @@ ipcMain.handle('add-account', async (event, { identifier }) => {
|
||||
loginName: existing.loginName || '', steamLoginSecure: existing.steamLoginSecure,
|
||||
loginConfig: existing.loginConfig, sessionUpdatedAt: existing.sessionUpdatedAt,
|
||||
autoCheckCooldown: !!existing.steamLoginSecure, status: (existing.vacBanned || existing.gameBans > 0) ? 'banned' : 'none',
|
||||
lastBanCheck: new Date().toISOString()
|
||||
lastBanCheck: new Date().toISOString(), sharedWith: existing.sharedWith
|
||||
};
|
||||
store.set('accounts', [...accounts, newAccount]);
|
||||
updateTrayMenu();
|
||||
@@ -488,19 +595,107 @@ ipcMain.handle('share-account-with-user', async (event, steamId: string, targetS
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
|
||||
ipcMain.handle('revoke-account-access', async (event, steamId: string, targetSteamId: string) => {
|
||||
initBackend();
|
||||
if (backend) return await backend.revokeAccess(steamId, targetSteamId);
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
|
||||
ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => {
|
||||
initBackend();
|
||||
if (backend) return await backend.revokeAllAccess(steamId);
|
||||
throw new Error('Backend not configured');
|
||||
});
|
||||
|
||||
ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
|
||||
ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
|
||||
ipcMain.handle('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName));
|
||||
|
||||
// --- Admin IPC ---
|
||||
ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; });
|
||||
ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
|
||||
ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBackend(); if (backend) await backend.deleteUser(userId); return true; });
|
||||
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
|
||||
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
|
||||
|
||||
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
|
||||
|
||||
ipcMain.handle('update-app-icon', (event, themeName: string) => {
|
||||
setAppIcon(themeName);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('switch-account', async (event, loginName: string) => {
|
||||
if (!loginName) return false;
|
||||
try {
|
||||
// PROACTIVE SYNC: Try to snag the freshest token before we kill Steam
|
||||
const accounts = store.get('accounts') as Account[];
|
||||
const account = accounts.find(a => a.loginName === loginName);
|
||||
if (account && !account._id.startsWith('shared_')) {
|
||||
const freshConfig = steamClient.extractAccountConfig(loginName);
|
||||
if (freshConfig) {
|
||||
account.loginConfig = freshConfig;
|
||||
account.sessionUpdatedAt = new Date().toISOString();
|
||||
if (backend) await backend.shareAccount(account);
|
||||
store.set('accounts', accounts);
|
||||
}
|
||||
}
|
||||
|
||||
await killSteam();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
|
||||
const commands = [
|
||||
`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,
|
||||
`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`
|
||||
];
|
||||
await new Promise<void>((res, rej) => exec(commands.join(' && '), (e) => e ? rej(e) : res()));
|
||||
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
|
||||
} else if (process.platform === 'linux') {
|
||||
await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
|
||||
}
|
||||
startSteam();
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));
|
||||
|
||||
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
|
||||
const loginSession = session.fromPartition('persist:steam-login');
|
||||
// Removed: automatic clearStorageData to allow cookie persistence
|
||||
ipcMain.handle('open-steam-app-login', async () => {
|
||||
await killSteam();
|
||||
if (process.platform === 'win32') {
|
||||
const clearReg = 'reg add "HKCU\\Software\\Valve\\Steam" /v AutoLoginUser /t REG_SZ /d "" /f';
|
||||
await new Promise<void>((res) => exec(clearReg, () => res()));
|
||||
} else if (process.platform === 'linux') {
|
||||
await steamClient.setAutoLoginUser("", undefined, "");
|
||||
}
|
||||
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
|
||||
exec(command);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
|
||||
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
|
||||
const loginSession = session.fromPartition(partitionId);
|
||||
if (!expectedSteamId) await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
|
||||
if (expectedSteamId) {
|
||||
const accounts = store.get('accounts') as Account[];
|
||||
const account = accounts.find(a => a.steamId === expectedSteamId);
|
||||
if (account?.steamLoginSecure) {
|
||||
const cookiePairs = account.steamLoginSecure.split(';').map(c => c.trim());
|
||||
for (const pair of cookiePairs) {
|
||||
const [name, value] = pair.split('=');
|
||||
if (name && value) {
|
||||
try { await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const loginWindow = new BrowserWindow({
|
||||
width: 800, height: 700, parent: mainWindow || undefined, modal: true, title: 'Login to Steam',
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: 'persist:steam-login' }
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, partition: partitionId }
|
||||
});
|
||||
loginWindow.loadURL('https://steamcommunity.com/login/home/?goto=my/gcpd/730');
|
||||
const checkCookie = setInterval(async () => {
|
||||
|
||||
@@ -7,7 +7,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
deleteAccount: (id: string) => ipcRenderer.invoke('delete-account', id),
|
||||
switchAccount: (loginName: string) => ipcRenderer.invoke('switch-account', loginName),
|
||||
shareAccountWithUser: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('share-account-with-user', steamId, targetSteamId),
|
||||
revokeAccountAccess: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId),
|
||||
revokeAllAccountAccess: (steamId: string) => ipcRenderer.invoke('revoke-all-account-access', steamId),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
||||
updateAppIcon: (theme: string) => ipcRenderer.invoke('update-app-icon', theme),
|
||||
openSteamAppLogin: () => ipcRenderer.invoke('open-steam-app-login'),
|
||||
openSteamLogin: (steamId: string) => ipcRenderer.invoke('open-steam-login', steamId),
|
||||
|
||||
// Server Config & Auth
|
||||
@@ -16,9 +20,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
loginToServer: () => ipcRenderer.invoke('login-to-server'),
|
||||
getServerUserInfo: () => ipcRenderer.invoke('get-server-user-info'),
|
||||
syncNow: () => ipcRenderer.invoke('sync-now'),
|
||||
scrapeAccount: (steamId: string) => ipcRenderer.invoke('scrape-account', steamId),
|
||||
getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'),
|
||||
getServerUsers: () => ipcRenderer.invoke('get-server-users'),
|
||||
|
||||
// Admin API
|
||||
adminGetStats: () => ipcRenderer.invoke('admin-get-stats'),
|
||||
adminGetUsers: () => ipcRenderer.invoke('admin-get-users'),
|
||||
adminDeleteUser: (userId: string) => ipcRenderer.invoke('admin-delete-user', userId),
|
||||
adminGetAccounts: () => ipcRenderer.invoke('admin-get-accounts'),
|
||||
adminRemoveAccount: (steamId: string) => ipcRenderer.invoke('admin-remove-account', steamId),
|
||||
|
||||
onAccountsUpdated: (callback: (accounts: any[]) => void) => {
|
||||
const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts);
|
||||
ipcRenderer.on('accounts-updated', subscription);
|
||||
|
||||
@@ -61,18 +61,23 @@ export class BackendService {
|
||||
gameBans: account.gameBans,
|
||||
loginName: account.loginName,
|
||||
steamLoginSecure: account.steamLoginSecure,
|
||||
loginConfig: account.loginConfig
|
||||
loginConfig: account.loginConfig,
|
||||
sessionUpdatedAt: account.sessionUpdatedAt,
|
||||
lastMetadataCheck: account.lastBanCheck,
|
||||
lastScrapeTime: account.lastScrapeTime,
|
||||
cooldownExpiresAt: account.cooldownExpiresAt
|
||||
}, { headers: this.headers });
|
||||
} catch (e) {
|
||||
console.error('[Backend] Failed to share account');
|
||||
}
|
||||
}
|
||||
|
||||
public async pushCooldown(steamId: string, cooldownExpiresAt?: string) {
|
||||
public async pushCooldown(steamId: string, cooldownExpiresAt?: string, lastScrapeTime?: string) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
await axios.patch(`${this.url}/api/sync/${steamId}/cooldown`, {
|
||||
cooldownExpiresAt
|
||||
cooldownExpiresAt,
|
||||
lastScrapeTime
|
||||
}, { headers: this.headers });
|
||||
} catch (e) {
|
||||
console.error(`[Backend] Failed to push cooldown for ${steamId}`);
|
||||
@@ -91,4 +96,75 @@ export class BackendService {
|
||||
throw new Error(e.response?.data?.message || 'Failed to share account');
|
||||
}
|
||||
}
|
||||
|
||||
public async revokeAccess(steamId: string, targetSteamId: string) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
const response = await axios.delete(`${this.url}/api/sync/${steamId}/share`, {
|
||||
headers: this.headers,
|
||||
data: { targetSteamId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
console.error(`[Backend] Failed to revoke access for ${steamId} from ${targetSteamId}`);
|
||||
throw new Error(e.response?.data?.message || 'Failed to revoke access');
|
||||
}
|
||||
}
|
||||
|
||||
public async revokeAllAccess(steamId: string) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
const response = await axios.delete(`${this.url}/api/sync/${steamId}/share/all`, {
|
||||
headers: this.headers
|
||||
});
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
console.error(`[Backend] Failed to revoke all access for ${steamId}`);
|
||||
throw new Error(e.response?.data?.message || 'Failed to revoke all access');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin API ---
|
||||
|
||||
public async getAdminStats() {
|
||||
if (!this.token) return null;
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/admin/stats`, { headers: this.headers });
|
||||
return response.data;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
public async getAdminUsers() {
|
||||
if (!this.token) return [];
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/admin/users`, { headers: this.headers });
|
||||
return response.data;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
public async deleteUser(userId: string) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
await axios.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
|
||||
} catch (e: any) {
|
||||
throw new Error(e.response?.data?.message || 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
public async getAdminAccounts() {
|
||||
if (!this.token) return [];
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
|
||||
return response.data;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
public async forceRemoveAccount(steamId: string) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
await axios.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
|
||||
} catch (e: any) {
|
||||
throw new Error(e.response?.data?.message || 'Failed to remove account');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface CooldownData {
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
// Custom error to identify session death
|
||||
export class SteamAuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SteamAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export const scrapeCooldown = async (steamId: string, steamLoginSecure: string): Promise<CooldownData> => {
|
||||
const url = `https://steamcommunity.com/profiles/${steamId}/gcpd/730?tab=matchmaking`;
|
||||
|
||||
@@ -15,43 +23,44 @@ export const scrapeCooldown = async (steamId: string, steamLoginSecure: string):
|
||||
'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
|
||||
timeout: 10000,
|
||||
validateStatus: (status) => status < 500 // Allow redirects to handle them manually
|
||||
});
|
||||
|
||||
// If Steam redirects us to the login page, the cookie is dead
|
||||
if (response.data.includes('Sign In') || response.request.path.includes('/login')) {
|
||||
throw new SteamAuthError('Invalid or expired steamLoginSecure cookie');
|
||||
}
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
if (response.data.includes('Sign In') || !response.data.includes('Personal Game Data')) {
|
||||
throw new Error('Invalid or expired steamLoginSecure cookie');
|
||||
if (!response.data.includes('Personal Game Data')) {
|
||||
throw new SteamAuthError('Session invalid: Personal Game Data not accessible');
|
||||
}
|
||||
|
||||
// 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'));
|
||||
const expirationIndex = headers.findIndex(h => h.includes('Competitive Cooldown Expiration') || h.includes('Cooldown Expiration'));
|
||||
|
||||
if (expirationIndex !== -1) {
|
||||
const firstRow = $(table).find('tr').not(':has(th)').first();
|
||||
const dateText = firstRow.find('td').eq(expirationIndex).text().trim();
|
||||
|
||||
const rows = $(table).find('tr').not(':has(th)');
|
||||
rows.each((_, row) => {
|
||||
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
|
||||
if (dateText && dateText !== '') {
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
expirationDate = parsed;
|
||||
if (!expirationDate || parsed > (expirationDate as Date)) 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
|
||||
};
|
||||
return { isActive: true, expiresAt: expirationDate };
|
||||
}
|
||||
|
||||
const content = $('#personal_game_data_content').text();
|
||||
@@ -61,7 +70,8 @@ export const scrapeCooldown = async (steamId: string, steamLoginSecure: string):
|
||||
|
||||
return { isActive: false };
|
||||
} catch (error: any) {
|
||||
console.error(`[Scraper] Error for ${steamId}:`, error.message);
|
||||
throw error;
|
||||
if (error instanceof SteamAuthError) throw error;
|
||||
console.error(`[Scraper] Network/Internal Error for ${steamId}:`, error.message);
|
||||
throw error; // Generic errors don't trigger re-auth
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,14 +26,16 @@ class SteamClientService {
|
||||
if (platform === 'win32') {
|
||||
const possiblePaths = [
|
||||
'C:\\Program Files (x86)\\Steam',
|
||||
'C:\\Program Files\\Steam'
|
||||
'C:\\Program Files\\Steam',
|
||||
path.join(process.env.APPDATA || '', 'Steam'),
|
||||
];
|
||||
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
|
||||
} 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')
|
||||
path.join(home, '.var/app/com.valvesoftware.Steam/.steam/steam'), // Flatpak
|
||||
path.join(home, 'snap/steam/common/.steam/steam'), // Snap
|
||||
];
|
||||
this.steamPath = possiblePaths.find(p => fs.existsSync(p)) || null;
|
||||
}
|
||||
@@ -53,13 +55,27 @@ class SteamClientService {
|
||||
return path.join(this.steamPath, 'config', 'config.vdf');
|
||||
}
|
||||
|
||||
private safeWriteVdf(filePath: string, data: any) {
|
||||
const tempPath = `${filePath}.tmp_${Date.now()}`;
|
||||
const dir = path.dirname(filePath);
|
||||
try {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const vdfContent = stringify(data);
|
||||
fs.writeFileSync(tempPath, vdfContent, 'utf-8');
|
||||
fs.renameSync(tempPath, filePath);
|
||||
} catch (e: any) {
|
||||
console.error(`[SteamClient] Atomic write failed for ${filePath}: ${e.message}`);
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public startWatching(callback: (accounts: LocalSteamAccount[]) => void) {
|
||||
this.onAccountsChanged = callback;
|
||||
const loginUsersPath = this.getLoginUsersPath();
|
||||
|
||||
if (loginUsersPath && fs.existsSync(loginUsersPath)) {
|
||||
this.readLocalAccounts();
|
||||
chokidar.watch(loginUsersPath, { persistent: true }).on('change', () => {
|
||||
chokidar.watch(loginUsersPath, { persistent: true, ignoreInitial: true }).on('change', () => {
|
||||
this.readLocalAccounts();
|
||||
});
|
||||
}
|
||||
@@ -68,79 +84,72 @@ class SteamClientService {
|
||||
private readLocalAccounts() {
|
||||
const filePath = this.getLoginUsersPath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (!content.trim()) return;
|
||||
const data = parse(content) as any;
|
||||
if (!data || !data.users) return;
|
||||
|
||||
const accounts: LocalSteamAccount[] = [];
|
||||
for (const [steamId64, userData] of Object.entries(data.users)) {
|
||||
const user = userData as any;
|
||||
if (!user || !user.AccountName) continue;
|
||||
accounts.push({
|
||||
steamId: steamId64,
|
||||
accountName: user.AccountName,
|
||||
personaName: user.PersonaName,
|
||||
steamId: steamId64, accountName: user.AccountName,
|
||||
personaName: user.PersonaName || user.AccountName,
|
||||
timestamp: parseInt(user.Timestamp) || 0
|
||||
});
|
||||
}
|
||||
|
||||
if (this.onAccountsChanged) this.onAccountsChanged(accounts);
|
||||
} catch (error) {
|
||||
console.error('[SteamClient] Error parsing loginusers.vdf:', error);
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
public extractAccountConfig(accountName: string): any | null {
|
||||
const configPath = this.getConfigVdfPath();
|
||||
if (!configPath || !fs.existsSync(configPath)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const data = parse(content) as any;
|
||||
|
||||
const accounts = data?.InstallConfigStore?.Software?.Valve?.Steam?.Accounts;
|
||||
if (accounts && accounts[accountName]) {
|
||||
return accounts[accountName];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SteamClient] Failed to extract config.vdf data');
|
||||
}
|
||||
return null;
|
||||
return (accounts && accounts[accountName]) ? accounts[accountName] : null;
|
||||
} catch (e) { 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;
|
||||
const parsed = parse(content) as any;
|
||||
if (parsed && typeof parsed === 'object') data = parsed;
|
||||
} 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 = {};
|
||||
const ensurePath = (obj: any, keys: string[]) => {
|
||||
let curr = obj;
|
||||
for (const key of keys) {
|
||||
if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {};
|
||||
curr = curr[key];
|
||||
}
|
||||
return curr;
|
||||
};
|
||||
|
||||
data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
|
||||
const steamAccounts = ensurePath(data, ['InstallConfigStore', 'Software', 'Valve', 'Steam', 'Accounts']);
|
||||
|
||||
// FAILPROOF: Force crucial flags that Steam uses to decide session validity
|
||||
steamAccounts[accountName] = {
|
||||
...accountData,
|
||||
RememberPassword: "1",
|
||||
AllowAutoLogin: "1",
|
||||
Timestamp: Math.floor(Date.now() / 1000).toString()
|
||||
};
|
||||
|
||||
try {
|
||||
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');
|
||||
}
|
||||
this.safeWriteVdf(configPath, data);
|
||||
console.log(`[SteamClient] Safely injected session for ${accountName}`);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise<boolean> {
|
||||
@@ -148,14 +157,12 @@ class SteamClientService {
|
||||
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;
|
||||
const parsed = parse(content) as any;
|
||||
if (parsed && parsed.users) data = parsed;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
@@ -164,7 +171,7 @@ class SteamClientService {
|
||||
let found = false;
|
||||
for (const [id, user] of Object.entries(data.users)) {
|
||||
const u = user as any;
|
||||
if (u.AccountName.toLowerCase() === accountName.toLowerCase()) {
|
||||
if (u.AccountName?.toLowerCase() === accountName.toLowerCase()) {
|
||||
u.mostrecent = "1";
|
||||
u.RememberPassword = "1";
|
||||
u.AllowAutoLogin = "1";
|
||||
@@ -177,8 +184,8 @@ class SteamClientService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && steamId) {
|
||||
console.log(`[SteamClient] Provisioning user ${accountName} into loginusers.vdf`);
|
||||
if (!found && steamId && accountName) {
|
||||
console.log(`[SteamClient] Provisioning new user profile for ${accountName}`);
|
||||
data.users[steamId] = {
|
||||
AccountName: accountName,
|
||||
PersonaName: accountName,
|
||||
@@ -193,16 +200,16 @@ class SteamClientService {
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(loginUsersPath, stringify(data));
|
||||
} catch (e) {
|
||||
console.error('[SteamClient] Failed to write loginusers.vdf');
|
||||
}
|
||||
this.safeWriteVdf(loginUsersPath, data);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (accountConfig) {
|
||||
// Injection of the actual authentication blob
|
||||
if (accountConfig && accountName) {
|
||||
this.injectAccountConfig(accountName, accountConfig);
|
||||
}
|
||||
|
||||
// --- Linux Registry / Registry.vdf Hardening ---
|
||||
if (platform === 'linux') {
|
||||
const regLocations = [
|
||||
path.join(os.homedir(), '.steam', 'registry.vdf'),
|
||||
@@ -210,37 +217,28 @@ class SteamClientService {
|
||||
];
|
||||
|
||||
for (const regPath of regLocations) {
|
||||
let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: {} } } } } };
|
||||
|
||||
if (!fs.existsSync(path.dirname(regPath))) continue;
|
||||
let regData: any = { Registry: { HKCU: { Software: { Valve: { Steam: { AutoLoginUser: "", RememberPassword: "1", AlreadyLoggedIn: "1" } } } } } };
|
||||
if (fs.existsSync(regPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(regPath, 'utf-8');
|
||||
regData = parse(content) as any;
|
||||
const parsed = parse(content) as any;
|
||||
if (parsed && typeof parsed === 'object') regData = parsed;
|
||||
} 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) => {
|
||||
const ensurePath = (obj: any, keys: 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;
|
||||
for (const key of keys) { if (!curr[key] || typeof curr[key] !== 'object') curr[key] = {}; curr = curr[key]; }
|
||||
return curr;
|
||||
};
|
||||
|
||||
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) { }
|
||||
const steamKey = ensurePath(regData, ['Registry', 'HKCU', 'Software', 'Valve', 'Steam']);
|
||||
steamKey.AutoLoginUser = accountName;
|
||||
steamKey.RememberPassword = "1";
|
||||
steamKey.AlreadyLoggedIn = "1";
|
||||
steamKey.WantsOfflineMode = "0";
|
||||
try { this.safeWriteVdf(regPath, regData); } catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>Ultimate Ban Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ultimate-ban-tracker-desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ultimate-ban-tracker-desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ultimate-ban-tracker-desktop",
|
||||
"description": "Professional Steam Account Manager & Ban Tracker",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.3",
|
||||
"author": "Nils Pukropp <nils@narl.io>",
|
||||
"homepage": "https://narl.io",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -28,7 +28,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
"dist-electron/**/*",
|
||||
"assets-build/**/*"
|
||||
],
|
||||
"linux": {
|
||||
"target": [
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ServerConfig {
|
||||
token?: string;
|
||||
serverSteamId?: string;
|
||||
enabled: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AccountsContextType {
|
||||
@@ -38,16 +39,27 @@ interface AccountsContextType {
|
||||
updateAccount: (id: string, data: Partial<Account>) => Promise<void>;
|
||||
deleteAccount: (id: string) => Promise<void>;
|
||||
switchAccount: (loginName: string) => Promise<void>;
|
||||
openSteamAppLogin: () => Promise<void>;
|
||||
openSteamLogin: (steamId: string) => Promise<void>;
|
||||
shareAccountWithUser: (steamId: string, targetSteamId: string) => Promise<any>;
|
||||
revokeAccountAccess: (steamId: string, targetSteamId: string) => Promise<any>;
|
||||
revokeAllAccountAccess: (steamId: string) => Promise<any>;
|
||||
|
||||
// Server Methods
|
||||
updateServerConfig: (config: Partial<ServerConfig>) => Promise<void>;
|
||||
loginToServer: () => Promise<void>;
|
||||
syncNow: () => Promise<void>;
|
||||
scrapeAccount: (steamId: string) => Promise<boolean>;
|
||||
getCommunityAccounts: () => Promise<any[]>;
|
||||
getServerUsers: () => Promise<any[]>;
|
||||
refreshAccounts: (showLoading?: boolean) => Promise<void>;
|
||||
|
||||
// Admin Methods
|
||||
adminGetStats: () => Promise<any>;
|
||||
adminGetUsers: () => Promise<any[]>;
|
||||
adminDeleteUser: (userId: string) => Promise<void>;
|
||||
adminGetAccounts: () => Promise<any[]>;
|
||||
adminRemoveAccount: (steamId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AccountsContext = createContext<AccountsContextType | undefined>(undefined);
|
||||
@@ -103,6 +115,12 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
}
|
||||
};
|
||||
|
||||
const scrapeAccount = async (steamId: string) => {
|
||||
const success = await (window as any).electronAPI.scrapeAccount(steamId);
|
||||
if (success) await syncNow();
|
||||
return success;
|
||||
};
|
||||
|
||||
const addAccount = async (data: { identifier: string }) => {
|
||||
await (window as any).electronAPI.addAccount(data);
|
||||
await refreshAccounts();
|
||||
@@ -125,6 +143,10 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
await (window as any).electronAPI.switchAccount(loginName);
|
||||
};
|
||||
|
||||
const openSteamAppLogin = async () => {
|
||||
await (window as any).electronAPI.openSteamAppLogin();
|
||||
};
|
||||
|
||||
const openSteamLogin = async (steamId: string) => {
|
||||
await (window as any).electronAPI.openSteamLogin(steamId);
|
||||
await syncNow();
|
||||
@@ -136,6 +158,18 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
return res;
|
||||
};
|
||||
|
||||
const revokeAccountAccess = async (steamId: string, targetSteamId: string) => {
|
||||
const res = await (window as any).electronAPI.revokeAccountAccess(steamId, targetSteamId);
|
||||
await syncNow();
|
||||
return res;
|
||||
};
|
||||
|
||||
const revokeAllAccountAccess = async (steamId: string) => {
|
||||
const res = await (window as any).electronAPI.revokeAllAccountAccess(steamId);
|
||||
await syncNow();
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateServerConfig = async (config: Partial<ServerConfig>) => {
|
||||
const updated = await (window as any).electronAPI.updateServerConfig(config);
|
||||
setServerConfig(updated);
|
||||
@@ -155,11 +189,19 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
return await (window as any).electronAPI.getServerUsers();
|
||||
};
|
||||
|
||||
// --- Admin Methods ---
|
||||
const adminGetStats = async () => (window as any).electronAPI.adminGetStats();
|
||||
const adminGetUsers = async () => (window as any).electronAPI.adminGetUsers();
|
||||
const adminDeleteUser = async (userId: string) => (window as any).electronAPI.adminDeleteUser(userId);
|
||||
const adminGetAccounts = async () => (window as any).electronAPI.adminGetAccounts();
|
||||
const adminRemoveAccount = async (steamId: string) => (window as any).electronAPI.adminRemoveAccount(steamId);
|
||||
|
||||
return (
|
||||
<AccountsContext.Provider value={{
|
||||
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
|
||||
switchAccount, openSteamLogin, updateServerConfig, loginToServer,
|
||||
getCommunityAccounts, getServerUsers, shareAccountWithUser, syncNow, refreshAccounts
|
||||
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer,
|
||||
getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts,
|
||||
scrapeAccount, adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
|
||||
}}>
|
||||
{children}
|
||||
</AccountsContext.Provider>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DialogActions, CircularProgress, Paper, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction,
|
||||
Tabs, Tab, Select, MenuItem, FormControl, InputLabel
|
||||
Select, MenuItem, FormControl, InputLabel, Tabs, Tab
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -20,75 +20,137 @@ 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 VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||
import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import { useAccounts, type Account } from '../hooks/useAccounts';
|
||||
import { useAppTheme } from '../theme/ThemeContext';
|
||||
import type { ThemeType } from '../theme/SteamTheme';
|
||||
import NebulaBanner from '../components/NebulaBanner';
|
||||
|
||||
const AdminPanel: React.FC<{ open: boolean, onClose: () => void }> = ({ open, onClose }) => {
|
||||
const { adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount } = useAccounts();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === 0) setStats(await adminGetStats());
|
||||
if (tab === 1) setUsers(await adminGetUsers());
|
||||
if (tab === 2) setAccounts(await adminGetAccounts());
|
||||
} catch (e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { if (open) loadData(); }, [open, tab]);
|
||||
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
if (window.confirm("Wipe this user and all their accounts?")) {
|
||||
await adminDeleteUser(id);
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceRemove = async (steamId: string) => {
|
||||
if (window.confirm("Force remove this account from server?")) {
|
||||
await adminRemoveAccount(steamId);
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ bgcolor: 'background.paper', color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AdminPanelSettingsIcon color="primary" /> Server Administration
|
||||
</DialogTitle>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab icon={<StorageIcon />} label="Overview" />
|
||||
<Tab icon={<GroupIcon />} label="Users" />
|
||||
<Tab icon={<AccountTreeIcon />} label="Global Accounts" />
|
||||
</Tabs>
|
||||
<DialogContent sx={{ bgcolor: 'background.paper', minHeight: 400, pt: 2 }}>
|
||||
{loading ? <Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}><CircularProgress /></Box> : (
|
||||
<>
|
||||
{tab === 0 && stats && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 2 }}>
|
||||
{[
|
||||
{ label: 'Total Users', value: stats.users },
|
||||
{ label: 'Total Accounts', value: stats.accounts },
|
||||
{ label: 'Active Cooldowns', value: stats.activeCooldowns }
|
||||
].map((s) => (
|
||||
<Paper key={s.label} sx={{ p: 3, textAlign: 'center', bgcolor: 'rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>{s.value}</Typography>
|
||||
<Typography variant="caption" color="textSecondary">{s.label}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{tab === 1 && (
|
||||
<List>
|
||||
{users.map(u => (
|
||||
<ListItem key={u._id} divider sx={{ borderColor: 'divider' }}>
|
||||
<Avatar src={u.avatar} sx={{ mr: 2 }} />
|
||||
<ListItemText primary={u.personaName} secondary={u.steamId} primaryTypographyProps={{ color: 'text.primary' }} />
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton color="error" onClick={() => handleDeleteUser(u._id)}><DeleteIcon /></IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{tab === 2 && (
|
||||
<List>
|
||||
{accounts.map(a => (
|
||||
<ListItem key={a.steamId} divider sx={{ borderColor: 'divider' }}>
|
||||
<Avatar src={a.avatar} variant="square" sx={{ mr: 2 }} />
|
||||
<ListItemText
|
||||
primary={a.personaName}
|
||||
secondary={`Owned by: ${a.addedBy?.personaName || 'Unknown'} (${a.steamId})`}
|
||||
primaryTypographyProps={{ color: 'text.primary' }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton color="error" onClick={() => handleForceRemove(a.steamId)}><DeleteIcon /></IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ bgcolor: 'background.paper', p: 2 }}>
|
||||
<Button onClick={onClose} variant="contained" color="inherit">Close Panel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { currentTheme, setTheme } = useAppTheme();
|
||||
const {
|
||||
accounts, isLoading, isSyncing, serverConfig, addAccount, deleteAccount,
|
||||
switchAccount, openSteamLogin, updateServerConfig, loginToServer,
|
||||
getCommunityAccounts, syncNow
|
||||
accounts, isLoading, isSyncing, serverConfig, deleteAccount,
|
||||
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer, 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 [isAdminPanelOpen, setIsAdminPanelOpen] = useState(false);
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig?.url) {
|
||||
setServerUrl(serverConfig.url);
|
||||
}
|
||||
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!");
|
||||
@@ -114,6 +176,15 @@ const Dashboard: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}>
|
||||
{/* Admin Button - Only visible if isAdmin is true */}
|
||||
{serverConfig?.isAdmin && (
|
||||
<Tooltip title="Open Admin Panel">
|
||||
<IconButton color="primary" onClick={() => setIsAdminPanelOpen(true)}>
|
||||
<AdminPanelSettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
|
||||
{isSyncing ? (
|
||||
<CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} />
|
||||
@@ -148,7 +219,7 @@ const Dashboard: React.FC = () => {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
onClick={() => openSteamAppLogin()}
|
||||
sx={{ height: 32 }}
|
||||
>
|
||||
Add
|
||||
@@ -194,7 +265,7 @@ const Dashboard: React.FC = () => {
|
||||
{!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!
|
||||
No accounts tracked. Click "Add" to get started!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -235,14 +306,7 @@ const Dashboard: React.FC = () => {
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={saveSettings}
|
||||
sx={{ height: 30 }}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="contained" size="small" onClick={saveSettings} sx={{ height: 30 }}>Apply</Button>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
@@ -298,61 +362,8 @@ const Dashboard: React.FC = () => {
|
||||
</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>
|
||||
{/* Admin Panel */}
|
||||
<AdminPanel open={isAdminPanelOpen} onClose={() => setIsAdminPanelOpen(false)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -365,21 +376,19 @@ const AccountRow: React.FC<{
|
||||
onSwitch: (login: string) => void,
|
||||
onAuth: () => void
|
||||
}> = ({ account, onDelete, onSwitch, onAuth }) => {
|
||||
const { shareAccountWithUser, getServerUsers, serverConfig } = useAccounts();
|
||||
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig, scrapeAccount } = useAccounts();
|
||||
const [timeLeft, setTimeLeft] = useState<string | null>(null);
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
const [targetUserId, setTargetUserId] = useState('');
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [serverUsers, setServerUsers] = useState<any[]>([]);
|
||||
|
||||
const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
|
||||
const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCooldownActive || !cooldownDate) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
if (!isCooldownActive || !cooldownDate) { setTimeLeft(null); return; }
|
||||
const targetTime = cooldownDate.getTime();
|
||||
const timer = setInterval(() => {
|
||||
const diff = targetTime - Date.now();
|
||||
@@ -392,14 +401,15 @@ const AccountRow: React.FC<{
|
||||
return () => clearInterval(timer);
|
||||
}, [account?.cooldownExpiresAt, isCooldownActive]);
|
||||
|
||||
const avatarSrc = account?.localAvatar
|
||||
? `steam-resource://${account.localAvatar}`
|
||||
: (account?.avatar || '');
|
||||
const avatarSrc = account?.localAvatar ? `steam-resource://${account.localAvatar}` : (account?.avatar || '');
|
||||
const [imgSrc, setImgSrc] = useState(avatarSrc);
|
||||
useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc(avatarSrc);
|
||||
}, [avatarSrc]);
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await scrapeAccount(account.steamId);
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const handleOpenShare = async () => {
|
||||
setIsShareOpen(true);
|
||||
@@ -409,8 +419,7 @@ const AccountRow: React.FC<{
|
||||
(window as any).electronAPI.getServerUserInfo()
|
||||
]);
|
||||
const filtered = (Array.isArray(users) ? users : []).filter(u =>
|
||||
u.steamId !== selfInfo.steamId &&
|
||||
u.steamId !== account.steamId
|
||||
u.steamId !== selfInfo.steamId && u.steamId !== account.steamId
|
||||
);
|
||||
setServerUsers(filtered);
|
||||
} catch (e) {}
|
||||
@@ -421,70 +430,79 @@ const AccountRow: React.FC<{
|
||||
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);
|
||||
}
|
||||
} catch (e: any) { alert(e.message || "Failed to share account");
|
||||
} finally { setIsSharing(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (targetSteamId: string) => {
|
||||
if (!window.confirm("Revoke access for this user?")) return;
|
||||
try { await revokeAccountAccess(account.steamId, targetSteamId);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleRevokeAll = async () => {
|
||||
if (!window.confirm("Completely stop sharing this account?")) return;
|
||||
try { await revokeAllAccountAccess(account.steamId); setIsShareOpen(false);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0);
|
||||
const isShared = account?._id.startsWith('shared_');
|
||||
|
||||
// Primary account check
|
||||
const isPrimaryAccount = serverConfig?.serverSteamId === account.steamId;
|
||||
|
||||
// Refined Shared Logic
|
||||
const isSharedWithYou = account?._id.startsWith('shared_');
|
||||
const hasSharedMembers = (account as any).sharedWith && (account as any).sharedWith.length > 0;
|
||||
const showCommunityIcon = isSharedWithYou || hasSharedMembers;
|
||||
|
||||
return (
|
||||
<TableRow sx={{ '&:hover': { background: 'action.hover' }, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<TableCell>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar src={imgSrc} variant="square" sx={{ width: 32, height: 32, border: '1px solid', borderColor: 'divider' }} />
|
||||
{isShared && (
|
||||
<Tooltip title="Community Shared Account">
|
||||
{isPrimaryAccount && (
|
||||
<Tooltip title="Primary Community Account">
|
||||
<WorkspacePremiumIcon sx={{ position: 'absolute', top: -8, left: -8, fontSize: 18, color: '#FFD700', filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.5))' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{showCommunityIcon && (
|
||||
<Tooltip title={isSharedWithYou ? "Remote Shared Account" : "Actively Shared with Community"}>
|
||||
<PeopleIcon sx={{ position: 'absolute', bottom: -4, right: -4, fontSize: 14, color: 'primary.main', bgcolor: 'background.default', borderRadius: '50%', border: '1px solid', borderColor: 'divider', p: 0.2 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: isBanned ? 'error.main' : 'text.primary' }}>
|
||||
{account?.personaName || 'Unknown'}
|
||||
</Typography>
|
||||
<Typography variant="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>
|
||||
<GppBadIcon sx={{ fontSize: 16 }} /><Typography variant="caption" sx={{ fontWeight: 'bold' }}>BANNED</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{account?.vacBanned && (
|
||||
<Chip label="VAC" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold', 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}
|
||||
{account?.vacBanned && <Chip label="VAC" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold' }} />}
|
||||
{account?.gameBans ? account.gameBans > 0 && <Chip label={`${account.gameBans} GAME`} size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold' }} /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'success.main' }}>
|
||||
<ShieldIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', letterSpacing: '0.5px' }}>SECURE</Typography>
|
||||
<ShieldIcon sx={{ fontSize: 16 }} /><Typography variant="caption" sx={{ fontWeight: 'bold' }}>SECURE</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{account?.authError ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', color: 'warning.main', gap: 0.5 }}>
|
||||
<LockResetIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>Needs Re-auth</Typography>
|
||||
<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>
|
||||
<TimerIcon sx={{ fontSize: 16 }} /><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{timeLeft}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Available</Typography>
|
||||
@@ -492,95 +510,84 @@ const AccountRow: React.FC<{
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, alignItems: 'center' }}>
|
||||
{/* Fast Switcher Button - Always available if we have a login name */}
|
||||
{account.loginName && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => onSwitch(account.loginName || '')}
|
||||
sx={{
|
||||
height: 28,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: 'secondary.main',
|
||||
'&:hover': { opacity: 0.9 },
|
||||
minWidth: 60
|
||||
}}
|
||||
>
|
||||
LOGIN
|
||||
</Button>
|
||||
variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')}
|
||||
sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 }, minWidth: 60 }}
|
||||
>LOGIN</Button>
|
||||
)}
|
||||
|
||||
{/* Scraper Auth Button - Controls the optional cooldown tracking */}
|
||||
<Tooltip title={account.steamLoginSecure && !account.authError ? "Session valid - Tracking active" : (account.steamLoginSecure ? "Refresh scraper session" : "Authenticate for cooldown tracking")}>
|
||||
<Tooltip title={account.steamLoginSecure && !account.authError ? "Tracking active" : "Authenticate for cooldowns"}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onAuth}
|
||||
disabled={!!(account.steamLoginSecure && !account.authError)}
|
||||
size="small" onClick={onAuth} disabled={!!(account.steamLoginSecure && !account.authError)}
|
||||
sx={{
|
||||
color: account.steamLoginSecure && !account.authError ? 'success.main' : (account.authError ? 'error.main' : 'warning.main'),
|
||||
border: '1px solid',
|
||||
borderColor: account.steamLoginSecure && !account.authError ? 'success.main' : 'divider',
|
||||
borderRadius: 1,
|
||||
opacity: account.steamLoginSecure && !account.authError ? 1 : 1,
|
||||
background: account.steamLoginSecure && !account.authError ? 'rgba(163, 207, 6, 0.1)' : 'transparent'
|
||||
border: '1px solid', borderColor: account.steamLoginSecure && !account.authError ? 'success.main' : 'divider',
|
||||
borderRadius: 1, background: account.steamLoginSecure && !account.authError ? 'rgba(163, 207, 6, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)}
|
||||
</IconButton>
|
||||
{account.steamLoginSecure && !account.authError && (
|
||||
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem', letterSpacing: '0.5px' }}>
|
||||
TRACKING
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem' }}>TRACKING</Typography>
|
||||
<IconButton size="small" onClick={handleRefresh} disabled={isRefreshing} sx={{ p: 0.2, color: 'text.secondary', '&:hover': { color: 'primary.main' } }}>
|
||||
{isRefreshing ? <CircularProgress size={10} color="inherit" /> : <SyncIcon sx={{ fontSize: 12 }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 0.5 }} />
|
||||
|
||||
<IconButton size="small" onClick={handleOpenShare} disabled={!serverConfig?.token}><ShareIcon fontSize="inherit" sx={{ color: 'primary.main' }}/></IconButton>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={() => (window as any).electronAPI.openExternal(account?.profileUrl || '')}><OpenInNewIcon fontSize="inherit"/></IconButton>
|
||||
<IconButton size="small" sx={{ color: 'error.main' }} onClick={() => onDelete(account?._id || '')}><DeleteIcon fontSize="inherit"/></IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Share Dialog */}
|
||||
<Dialog open={isShareOpen} onClose={() => setIsShareOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Share Account</DialogTitle>
|
||||
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Permissions</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 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>GRANT ACCESS</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: 'text.secondary' }}>Select User</InputLabel>
|
||||
<Select
|
||||
value={targetUserId}
|
||||
label="Select User"
|
||||
onChange={(e) => setTargetUserId(e.target.value as string)}
|
||||
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 => (
|
||||
{serverUsers
|
||||
.filter(u => !(account as any).sharedWith?.find((sw: any) => sw.steamId === u.steamId))
|
||||
.map(user => (
|
||||
<MenuItem key={user.steamId} value={user.steamId}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar src={user.avatar} sx={{ width: 24, height: 24 }} />
|
||||
{user.personaName}
|
||||
</Box>
|
||||
<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>}
|
||||
{serverUsers.length === 0 && <MenuItem disabled>No eligible users found</MenuItem>}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button onClick={handleShare} variant="contained" disabled={!targetUserId || isSharing} sx={{ minWidth: 80 }}>{isSharing ? <CircularProgress size={16} color="inherit" /> : "Add"}</Button>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2, borderColor: 'divider' }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>CURRENT ACCESS</Typography>
|
||||
<List size="small" sx={{ bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1, mb: 2 }}>
|
||||
{(account as any).sharedWith?.map((sw: any) => (
|
||||
<ListItem key={sw.steamId} dense divider sx={{ borderColor: 'divider' }}>
|
||||
<Avatar src={sw.avatar} sx={{ width: 24, height: 24, mr: 1 }} />
|
||||
<ListItemText primary={sw.personaName} primaryTypographyProps={{ variant: 'body2', sx: { fontWeight: 'bold' } }} />
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton size="small" color="error" onClick={() => handleRevoke(sw.steamId)}><DeleteIcon fontSize="inherit" /></IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
{(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && (
|
||||
<Typography variant="caption" align="center" sx={{ display: 'block', p: 2, opacity: 0.6 }}>Not shared with anyone yet.</Typography>
|
||||
)}
|
||||
</List>
|
||||
{(account as any).sharedWith?.length > 0 && (
|
||||
<Button fullWidth variant="outlined" color="error" size="small" onClick={handleRevokeAll} startIcon={<GppBadIcon />}>Revoke All Shared Access</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}>
|
||||
<Button onClick={() => setIsShareOpen(false)} color="inherit" 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>
|
||||
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}><Button onClick={() => setIsShareOpen(false)} color="inherit" variant="contained">Done</Button></DialogActions>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
234
frontend/src/pages/DashboardRow.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
const AccountRow: React.FC<{
|
||||
account: Account,
|
||||
onDelete: (id: string) => void,
|
||||
onSwitch: (login: string) => void,
|
||||
onAuth: () => void
|
||||
}> = ({ account, onDelete, onSwitch, onAuth }) => {
|
||||
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig } = useAccounts();
|
||||
const [timeLeft, setTimeLeft] = useState<string | null>(null);
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
const [targetUserId, setTargetUserId] = useState('');
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [serverUsers, setServerUsers] = useState<any[]>([]);
|
||||
|
||||
const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null;
|
||||
const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCooldownActive || !cooldownDate) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
const targetTime = cooldownDate.getTime();
|
||||
const timer = setInterval(() => {
|
||||
const diff = targetTime - Date.now();
|
||||
if (diff <= 0) { setTimeLeft(null); clearInterval(timer); return; }
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.floor((diff % 3600000) / 60000);
|
||||
const secs = Math.floor((diff % 60000) / 1000);
|
||||
setTimeLeft(`${hours}h ${mins}m ${secs}s`);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [account?.cooldownExpiresAt, isCooldownActive]);
|
||||
|
||||
const avatarSrc = account?.localAvatar
|
||||
? `steam-resource://${account.localAvatar}`
|
||||
: (account?.avatar || '');
|
||||
const [imgSrc, setImgSrc] = useState(avatarSrc);
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc(avatarSrc);
|
||||
}, [avatarSrc]);
|
||||
|
||||
const handleOpenShare = async () => {
|
||||
setIsShareOpen(true);
|
||||
try {
|
||||
const [users, selfInfo] = await Promise.all([
|
||||
getServerUsers(),
|
||||
(window as any).electronAPI.getServerUserInfo()
|
||||
]);
|
||||
const filtered = (Array.isArray(users) ? users : []).filter(u =>
|
||||
u.steamId !== selfInfo.steamId &&
|
||||
u.steamId !== account.steamId
|
||||
);
|
||||
setServerUsers(filtered);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!targetUserId) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
await shareAccountWithUser(account.steamId, targetUserId);
|
||||
setTargetUserId('');
|
||||
} catch (e: any) {
|
||||
alert(e.message || "Failed to share account");
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (targetSteamId: string) => {
|
||||
if (!window.confirm("Revoke access for this user?")) return;
|
||||
try {
|
||||
await revokeAccountAccess(account.steamId, targetSteamId);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleRevokeAll = async () => {
|
||||
if (!window.confirm("Completely stop sharing this account with the community?")) return;
|
||||
try {
|
||||
await revokeAllAccountAccess(account.steamId);
|
||||
setIsShareOpen(false);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0);
|
||||
const isShared = account?._id.startsWith('shared_');
|
||||
|
||||
return (
|
||||
<TableRow sx={{ '&:hover': { background: 'action.hover' }, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<TableCell>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar src={imgSrc} variant="square" sx={{ width: 32, height: 32, border: '1px solid', borderColor: 'divider' }} />
|
||||
{isShared && (
|
||||
<Tooltip title="Community Shared Account">
|
||||
<PeopleIcon sx={{ position: 'absolute', bottom: -4, right: -4, fontSize: 14, color: 'primary.main', bgcolor: 'background.default', borderRadius: '50%', border: '1px solid', borderColor: 'divider', p: 0.2 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: isBanned ? 'error.main' : 'text.primary' }}>
|
||||
{account?.personaName || 'Unknown'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{account?.steamId}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isBanned ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'error.main' }}>
|
||||
<GppBadIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', letterSpacing: '0.5px' }}>ACCOUNT BANNED</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{account?.vacBanned && (
|
||||
<Chip label="VAC" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold', borderRadius: 0.5 }} />
|
||||
)}
|
||||
{account?.gameBans ? account.gameBans > 0 && (
|
||||
<Chip label={`${account.gameBans} GAME`} size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: 'error.main', color: 'white', fontWeight: 'bold', borderRadius: 0.5 }} />
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'success.main' }}>
|
||||
<ShieldIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', letterSpacing: '0.5px' }}>SECURE</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{account?.authError ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', color: 'warning.main', gap: 0.5 }}>
|
||||
<LockResetIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>Needs Re-auth</Typography>
|
||||
</Box>
|
||||
) : isCooldownActive ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', color: 'primary.main', gap: 0.5 }}>
|
||||
<TimerIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{timeLeft}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Available</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, alignItems: 'center' }}>
|
||||
{account.loginName && (
|
||||
<Button
|
||||
variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')}
|
||||
sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 }, minWidth: 60 }}
|
||||
>
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tooltip title={account.steamLoginSecure && !account.authError ? "Session valid - Tracking active" : (account.steamLoginSecure ? "Refresh scraper session" : "Authenticate for cooldown tracking")}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small" onClick={onAuth}
|
||||
disabled={!!(account.steamLoginSecure && !account.authError)}
|
||||
sx={{
|
||||
color: account.steamLoginSecure && !account.authError ? 'success.main' : (account.authError ? 'error.main' : 'warning.main'),
|
||||
border: '1px solid', borderColor: account.steamLoginSecure && !account.authError ? 'success.main' : 'divider',
|
||||
borderRadius: 1, background: account.steamLoginSecure && !account.authError ? 'rgba(163, 207, 6, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{account.steamLoginSecure && !account.authError ? <VerifiedUserIcon fontSize="inherit" /> : (account.authError ? <LockResetIcon fontSize="inherit" /> : <BoltIcon fontSize="inherit" />)}
|
||||
</IconButton>
|
||||
{account.steamLoginSecure && !account.authError && (
|
||||
<Typography variant="caption" sx={{ color: 'success.main', fontWeight: 'bold', fontSize: '0.6rem', letterSpacing: '0.5px' }}>
|
||||
TRACKING
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 0.5 }} />
|
||||
|
||||
<IconButton size="small" onClick={handleOpenShare} disabled={!serverConfig?.token}><ShareIcon fontSize="inherit" sx={{ color: 'primary.main' }}/></IconButton>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={() => (window as any).electronAPI.openExternal(account?.profileUrl || '')}><OpenInNewIcon fontSize="inherit"/></IconButton>
|
||||
<IconButton size="small" sx={{ color: 'error.main' }} onClick={() => onDelete(account?._id || '')}><DeleteIcon fontSize="inherit"/></IconButton>
|
||||
</Box>
|
||||
|
||||
<Dialog open={isShareOpen} onClose={() => setIsShareOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>Account Permissions</DialogTitle>
|
||||
<DialogContent sx={{ backgroundColor: 'background.paper', pt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>GRANT ACCESS</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: 'text.secondary' }}>Select User</InputLabel>
|
||||
<Select
|
||||
value={targetUserId} label="Select User" onChange={(e) => setTargetUserId(e.target.value as string)}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.1)', color: 'text.primary' }}
|
||||
>
|
||||
{serverUsers
|
||||
.filter(u => !(account as any).sharedWith?.find((sw: any) => sw.steamId === u.steamId))
|
||||
.map(user => (
|
||||
<MenuItem key={user.steamId} value={user.steamId}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><Avatar src={user.avatar} sx={{ width: 24, height: 24 }} />{user.personaName}</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
{serverUsers.length === 0 && <MenuItem disabled>No eligible users found</MenuItem>}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button onClick={handleShare} variant="contained" disabled={!targetUserId || isSharing} sx={{ minWidth: 80 }}>
|
||||
{isSharing ? <CircularProgress size={16} color="inherit" /> : "Add"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2, borderColor: 'divider' }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>CURRENT ACCESS</Typography>
|
||||
<List size="small" sx={{ bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1, mb: 2 }}>
|
||||
{(account as any).sharedWith?.map((sw: any) => (
|
||||
<ListItem key={sw.steamId} dense divider sx={{ borderColor: 'divider' }}>
|
||||
<Avatar src={sw.avatar} sx={{ width: 24, height: 24, mr: 1 }} />
|
||||
<ListItemText primary={sw.personaName} primaryTypographyProps={{ variant: 'body2', sx: { fontWeight: 'bold' } }} />
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton size="small" color="error" onClick={() => handleRevoke(sw.steamId)}><DeleteIcon fontSize="inherit" /></IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
{(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && (
|
||||
<Typography variant="caption" align="center" sx={{ display: 'block', p: 2, opacity: 0.6 }}>Not shared with anyone yet.</Typography>
|
||||
)}
|
||||
</List>
|
||||
{(account as any).sharedWith?.length > 0 && (
|
||||
<Button fullWidth variant="outlined" color="error" size="small" onClick={handleRevokeAll} startIcon={<GppBadIcon />}>Revoke All Shared Access</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ backgroundColor: 'background.paper', p: 2 }}><Button onClick={() => setIsShareOpen(false)} color="inherit" variant="contained">Done</Button></DialogActions>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -31,8 +31,27 @@ export const AppThemeProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
if (api?.updateServerConfig) {
|
||||
await api.updateServerConfig({ theme });
|
||||
}
|
||||
if (api?.updateAppIcon) {
|
||||
try {
|
||||
await api.updateAppIcon(theme);
|
||||
} catch (e) { }
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateIcon = async () => {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.updateAppIcon && currentTheme) {
|
||||
try {
|
||||
await api.updateAppIcon(currentTheme);
|
||||
} catch (e) {
|
||||
console.warn("[ThemeContext] updateAppIcon failed (likely not registered yet)");
|
||||
}
|
||||
}
|
||||
};
|
||||
updateIcon();
|
||||
}, [currentTheme]);
|
||||
|
||||
const theme = useMemo(() => getTheme(currentTheme), [currentTheme]);
|
||||
|
||||
return (
|
||||
|
||||