Compare commits
15 Commits
v1.3.0
...
2124845848
| Author | SHA1 | Date | |
|---|---|---|---|
| 2124845848 | |||
| 4ad4e1c9de | |||
| 3f7c325604 | |||
| 776e05fb52 | |||
| fc19f66ace | |||
| eca3a728fc | |||
| b64ddafab9 | |||
| 9174bcfca2 | |||
| d30005acbd | |||
| a5cc155ffc | |||
| 276d3bd4de | |||
| 60b3dd1ca1 | |||
| 34a71de2dc | |||
| 83dbfce8b2 | |||
| c208ecea95 |
@@ -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,37 +60,55 @@ const initBackend = () => {
|
||||
};
|
||||
// --- System Tray ---
|
||||
const createTray = () => {
|
||||
const assetsDir = path_1.default.join(__dirname, '..', 'assets-build');
|
||||
const possibleIcons = ['icon.svg', 'icon.png'];
|
||||
let iconPath = '';
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path_1.default.join(assetsDir, name);
|
||||
if (fs_1.default.existsSync(fullPath)) {
|
||||
iconPath = fullPath;
|
||||
// Try to find the icon in various standard locations
|
||||
const possiblePaths = [
|
||||
path_1.default.join(__dirname, '..', 'assets-build'), // Dev
|
||||
path_1.default.join(process.resourcesPath, 'assets-build'), // Packaged (External)
|
||||
path_1.default.join(electron_1.app.getAppPath(), 'dist', 'assets-build'), // Packaged (Internal dist)
|
||||
path_1.default.join(electron_1.app.getAppPath(), 'assets-build') // Packaged (Internal root)
|
||||
];
|
||||
let assetsDir = '';
|
||||
for (const p of possiblePaths) {
|
||||
if (fs_1.default.existsSync(p)) {
|
||||
assetsDir = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const possibleIcons = ['icon.png', 'icon.svg'];
|
||||
let iconPath = '';
|
||||
if (assetsDir) {
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path_1.default.join(assetsDir, name);
|
||||
if (fs_1.default.existsSync(fullPath)) {
|
||||
iconPath = fullPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
|
||||
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
|
||||
if (!iconPath) {
|
||||
console.warn(`[Tray] FAILED: No valid icon found in ${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) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
updateTrayMenu();
|
||||
console.log(`[Tray] Successfully initialized`);
|
||||
}
|
||||
catch (e) {
|
||||
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)
|
||||
@@ -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,8 +184,58 @@ const handleSwitchAccount = async (loginName) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// --- Scraper Helper ---
|
||||
const scrapeAccountData = async (account) => {
|
||||
const now = new Date();
|
||||
try {
|
||||
const profile = await (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
|
||||
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
|
||||
account.personaName = profile.personaName;
|
||||
account.profileUrl = profile.profileUrl;
|
||||
account.vacBanned = bans.vacBanned;
|
||||
account.gameBans = bans.gameBans;
|
||||
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
|
||||
account.lastBanCheck = now.toISOString();
|
||||
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
|
||||
account.avatar = profile.avatar;
|
||||
const localPath = await downloadAvatar(account.steamId, profile.avatar);
|
||||
if (localPath)
|
||||
account.localAvatar = localPath;
|
||||
}
|
||||
if (account.steamLoginSecure) {
|
||||
try {
|
||||
const result = await (0, scraper_1.scrapeCooldown)(account.steamId, account.steamLoginSecure);
|
||||
account.authError = false;
|
||||
account.lastScrapeTime = now.toISOString();
|
||||
if (result.isActive) {
|
||||
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
|
||||
if (backend)
|
||||
await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString());
|
||||
}
|
||||
else {
|
||||
account.cooldownExpiresAt = undefined;
|
||||
if (backend)
|
||||
await backend.pushCooldown(account.steamId, undefined, now.toISOString());
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e.message.includes('cookie') || e.message.includes('Sign In'))
|
||||
account.authError = true;
|
||||
}
|
||||
}
|
||||
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');
|
||||
let hasChanges = false;
|
||||
@@ -167,12 +246,13 @@ 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;
|
||||
}
|
||||
@@ -192,8 +272,28 @@ const syncAccounts = async () => {
|
||||
exists.sessionUpdatedAt = s.sessionUpdatedAt;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
|
||||
// Metadata Sync (Pull)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -207,88 +307,44 @@ const syncAccounts = async () => {
|
||||
mainWindow.webContents.send('accounts-updated', accounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
if (accounts.length === 0)
|
||||
return;
|
||||
const updatedAccounts = [...accounts];
|
||||
let scrapeChanges = false;
|
||||
for (const account of updatedAccounts) {
|
||||
try {
|
||||
const now = new Date();
|
||||
// OPTIMIZATION: Ensure ALL authenticated accounts are shared with the server on every sync cycle
|
||||
// this guarantees that even if a push failed previously, it will be reconciled now.
|
||||
if (backend && !account._id.startsWith('shared_')) {
|
||||
console.log(`[Sync] Reconciling account with server: ${account.personaName}`);
|
||||
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 (0, steam_web_1.fetchProfileData)(account.steamId, account.steamLoginSecure);
|
||||
const bans = await (0, steam_web_1.scrapeBanStatus)(profile.profileUrl, account.steamLoginSecure);
|
||||
account.personaName = profile.personaName;
|
||||
account.profileUrl = profile.profileUrl;
|
||||
account.vacBanned = bans.vacBanned;
|
||||
account.gameBans = bans.gameBans;
|
||||
account.status = (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none';
|
||||
account.lastBanCheck = now.toISOString();
|
||||
if (profile.avatar && (!account.localAvatar || profile.avatar !== account.avatar)) {
|
||||
account.avatar = profile.avatar;
|
||||
const localPath = await downloadAvatar(account.steamId, profile.avatar);
|
||||
if (localPath)
|
||||
account.localAvatar = localPath;
|
||||
}
|
||||
if (account.loginName) {
|
||||
const config = steam_client_1.steamClient.extractAccountConfig(account.loginName);
|
||||
if (config) {
|
||||
account.loginConfig = config;
|
||||
account.sessionUpdatedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
if (backend)
|
||||
// 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);
|
||||
scrapeChanges = true;
|
||||
}
|
||||
if (account.autoCheckCooldown && account.steamLoginSecure) {
|
||||
if (account.cooldownExpiresAt && new Date(account.cooldownExpiresAt) > now)
|
||||
continue;
|
||||
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
|
||||
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 (0, scraper_1.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);
|
||||
}
|
||||
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 (e) {
|
||||
if (e.message.includes('cookie') || e.message.includes('Sign In')) {
|
||||
account.authError = true;
|
||||
scrapeChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) { }
|
||||
}
|
||||
catch (error) { }
|
||||
}
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', updatedAccounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', updatedAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', currentAccounts);
|
||||
if (mainWindow)
|
||||
mainWindow.webContents.send('accounts-updated', currentAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
console.log('[Sync] Sync cycle finished.');
|
||||
};
|
||||
if (isManual)
|
||||
await runScrapes();
|
||||
else
|
||||
runScrapes();
|
||||
};
|
||||
const scheduleNextSync = () => {
|
||||
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
|
||||
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
|
||||
};
|
||||
// --- Discovery ---
|
||||
const addingAccounts = new Set();
|
||||
@@ -311,11 +367,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()
|
||||
});
|
||||
@@ -332,7 +397,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,
|
||||
@@ -351,7 +416,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://', ''));
|
||||
@@ -370,7 +434,7 @@ electron_1.app.whenReady().then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
initBackend();
|
||||
setTimeout(syncAccounts, 5000);
|
||||
setTimeout(() => syncAccounts(false), 5000);
|
||||
scheduleNextSync();
|
||||
steam_client_1.steamClient.startWatching(handleLocalAccountsFound);
|
||||
});
|
||||
@@ -397,7 +461,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`);
|
||||
@@ -439,7 +503,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();
|
||||
@@ -457,7 +535,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();
|
||||
@@ -532,18 +610,59 @@ electron_1.ipcMain.handle('admin-delete-user', async (event, userId) => { initBa
|
||||
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('switch-account', async (event, loginName) => await handleSwitchAccount(loginName));
|
||||
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 () => {
|
||||
console.log('[SteamClient] Preparing for fresh login...');
|
||||
await killSteam();
|
||||
if (process.platform === 'win32') {
|
||||
// Clear auto-login registry
|
||||
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') {
|
||||
// On Linux we can use the steamClient helper to set an empty user
|
||||
await steam_client_1.steamClient.setAutoLoginUser("", undefined, "");
|
||||
}
|
||||
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
|
||||
@@ -551,34 +670,20 @@ electron_1.ipcMain.handle('open-steam-app-login', async () => {
|
||||
return true;
|
||||
});
|
||||
electron_1.ipcMain.handle('open-steam-login', async (event, expectedSteamId) => {
|
||||
// Use a unique partition per account to prevent session bleeding
|
||||
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
|
||||
const loginSession = electron_1.session.fromPartition(partitionId);
|
||||
// If adding a brand new account, explicitly clear previous trash
|
||||
if (!expectedSteamId) {
|
||||
console.log('[Auth] Clearing session for new account login...');
|
||||
if (!expectedSteamId)
|
||||
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
|
||||
}
|
||||
// If we have an existing cookie string for this account, pre-inject it
|
||||
if (expectedSteamId) {
|
||||
const accounts = store.get('accounts');
|
||||
const account = accounts.find(a => a.steamId === expectedSteamId);
|
||||
if (account?.steamLoginSecure) {
|
||||
console.log(`[Auth] Pre-injecting existing cookies for ${account.personaName}...`);
|
||||
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: name,
|
||||
value: value,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: name.includes('Secure')
|
||||
});
|
||||
await loginSession.cookies.set({ url: 'https://steamcommunity.com', domain: 'steamcommunity.com', name, value, path: '/', secure: true, httpOnly: name.includes('Secure') });
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ electron_1.contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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
|
||||
@@ -19,6 +20,7 @@ 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
|
||||
|
||||
@@ -68,19 +68,23 @@ class BackendService {
|
||||
loginName: account.loginName,
|
||||
steamLoginSecure: account.steamLoginSecure,
|
||||
loginConfig: account.loginConfig,
|
||||
sessionUpdatedAt: account.sessionUpdatedAt
|
||||
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) {
|
||||
|
||||
@@ -57,17 +57,23 @@ const scrapeCooldown = async (steamId, steamLoginSecure) => {
|
||||
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();
|
||||
if (dateText && dateText !== '') {
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
expirationDate = parsed;
|
||||
const rows = $(table).find('tr').not(':has(th)');
|
||||
rows.each((_, row) => {
|
||||
const dateText = $(row).find('td').eq(expirationIndex).text().trim();
|
||||
if (dateText && dateText !== '') {
|
||||
// Steam uses 'GMT' which some JS engines don't parse well, replace with 'UTC'
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
// We want the newest expiration date found
|
||||
if (!expirationDate || parsed > expirationDate) {
|
||||
expirationDate = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (expirationDate && expirationDate.getTime() > Date.now()) {
|
||||
|
||||
@@ -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,35 @@ class SteamClientService {
|
||||
return null;
|
||||
return path_1.default.join(this.steamPath, 'config', 'config.vdf');
|
||||
}
|
||||
/**
|
||||
* Safe Atomic Write: Writes to a temp file and renames it.
|
||||
* This prevents file corruption if the app crashes during write.
|
||||
*/
|
||||
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');
|
||||
// Atomic rename
|
||||
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', () => {
|
||||
console.log(`[SteamClient] loginusers.vdf changed, re-scanning...`);
|
||||
this.readLocalAccounts();
|
||||
});
|
||||
}
|
||||
@@ -63,16 +88,20 @@ class SteamClientService {
|
||||
return;
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
||||
if (!content.trim())
|
||||
return; // Empty file
|
||||
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,
|
||||
personaName: user.PersonaName || user.AccountName,
|
||||
timestamp: parseInt(user.Timestamp) || 0
|
||||
});
|
||||
}
|
||||
@@ -91,32 +120,38 @@ 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;
|
||||
}
|
||||
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: {} } } } } };
|
||||
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
|
||||
// Ensure safe nesting
|
||||
if (!data.InstallConfigStore)
|
||||
data.InstallConfigStore = {};
|
||||
if (!data.InstallConfigStore.Software)
|
||||
@@ -129,25 +164,22 @@ class SteamClientService {
|
||||
data.InstallConfigStore.Software.Valve.Steam.Accounts = {};
|
||||
data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData;
|
||||
try {
|
||||
fs_1.default.writeFileSync(configPath, (0, simple_vdf_1.stringify)(data));
|
||||
console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`);
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[SteamClient] Failed to write config.vdf');
|
||||
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 +188,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 +201,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 +216,53 @@ class SteamClientService {
|
||||
};
|
||||
}
|
||||
try {
|
||||
fs_1.default.writeFileSync(loginUsersPath, (0, simple_vdf_1.stringify)(data));
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[SteamClient] Failed to write loginusers.vdf');
|
||||
this.safeWriteVdf(loginUsersPath, data);
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
if (accountConfig) {
|
||||
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) => {
|
||||
// Deep merge helper
|
||||
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) { }
|
||||
}
|
||||
|
||||
@@ -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,22 +93,40 @@ const initBackend = () => {
|
||||
|
||||
// --- System Tray ---
|
||||
const createTray = () => {
|
||||
const assetsDir = path.join(__dirname, '..', 'assets-build');
|
||||
const possibleIcons = ['icon.svg', 'icon.png'];
|
||||
let iconPath = '';
|
||||
// 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)
|
||||
];
|
||||
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path.join(assetsDir, name);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
iconPath = fullPath;
|
||||
let assetsDir = '';
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
assetsDir = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const possibleIcons = ['icon.png', 'icon.svg'];
|
||||
let iconPath = '';
|
||||
|
||||
if (assetsDir) {
|
||||
for (const name of possibleIcons) {
|
||||
const fullPath = path.join(assetsDir, name);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
iconPath = fullPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`);
|
||||
console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`);
|
||||
|
||||
if (!iconPath) {
|
||||
console.warn(`[Tray] FAILED: No valid icon found in ${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,49 @@ 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.message.includes('cookie') || e.message.includes('Sign In')) account.authError = true;
|
||||
}
|
||||
}
|
||||
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,12 +274,13 @@ 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 {
|
||||
@@ -219,8 +293,31 @@ const syncAccounts = async () => {
|
||||
exists.sessionUpdatedAt = s.sessionUpdatedAt;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) {
|
||||
|
||||
// Metadata Sync (Pull)
|
||||
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,76 +331,39 @@ const syncAccounts = async () => {
|
||||
updateTrayMenu();
|
||||
}
|
||||
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const updatedAccounts = [...accounts];
|
||||
let scrapeChanges = false;
|
||||
|
||||
for (const account of updatedAccounts) {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// OPTIMIZATION: Ensure ALL authenticated accounts are shared with the server on every sync cycle
|
||||
// this guarantees that even if a push failed previously, it will be reconciled now.
|
||||
if (backend && !account._id.startsWith('shared_')) {
|
||||
console.log(`[Sync] Reconciling account with server: ${account.personaName}`);
|
||||
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;
|
||||
// Phase 2: Background Scrapes
|
||||
const runScrapes = async () => {
|
||||
console.log(`[Sync] Phase 2: Starting background checks for ${accounts.length} accounts...`);
|
||||
const currentAccounts = [...store.get('accounts') as Account[]];
|
||||
let scrapeChanges = false;
|
||||
for (const account of currentAccounts) {
|
||||
try {
|
||||
const now = new Date();
|
||||
if (backend && !account._id.startsWith('shared_')) await backend.shareAccount(account);
|
||||
const lastCheck = account.lastBanCheck ? new Date(account.lastBanCheck) : new Date(0);
|
||||
const lastScrape = account.lastScrapeTime ? new Date(account.lastScrapeTime) : new Date(0);
|
||||
if ((now.getTime() - lastScrape.getTime()) / 3600000 > 8) {
|
||||
await new Promise(r => setTimeout(r, Math.floor(Math.random() * 60000) + 5000));
|
||||
try {
|
||||
const result = await scrapeCooldown(account.steamId, account.steamLoginSecure);
|
||||
account.authError = false; account.lastScrapeTime = new Date().toISOString();
|
||||
if (result.isActive) {
|
||||
account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString();
|
||||
if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt);
|
||||
} else if (account.cooldownExpiresAt) {
|
||||
account.cooldownExpiresAt = undefined;
|
||||
if (backend) await backend.pushCooldown(account.steamId, undefined);
|
||||
}
|
||||
scrapeChanges = true;
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('cookie') || e.message.includes('Sign In')) { account.authError = true; scrapeChanges = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
const needsMetadata = (now.getTime() - lastCheck.getTime()) / 3600000 > 6 || !account.personaName;
|
||||
const needsCooldown = account.autoCheckCooldown && account.steamLoginSecure && (now.getTime() - lastScrape.getTime()) / 3600000 > 8;
|
||||
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', updatedAccounts);
|
||||
if (mainWindow) mainWindow.webContents.send('accounts-updated', updatedAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
if (needsMetadata || needsCooldown || isManual) {
|
||||
if (!isManual && needsCooldown) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 5000));
|
||||
if (await scrapeAccountData(account)) scrapeChanges = true;
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
if (scrapeChanges) {
|
||||
store.set('accounts', currentAccounts);
|
||||
if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts);
|
||||
updateTrayMenu();
|
||||
}
|
||||
console.log('[Sync] Sync cycle finished.');
|
||||
};
|
||||
|
||||
if (isManual) await runScrapes(); else runScrapes();
|
||||
};
|
||||
|
||||
const scheduleNextSync = () => {
|
||||
setTimeout(async () => { await syncAccounts(); scheduleNextSync(); }, isDev ? 120000 : 1800000);
|
||||
setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000);
|
||||
};
|
||||
|
||||
// --- Discovery ---
|
||||
@@ -322,11 +382,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()
|
||||
});
|
||||
@@ -342,28 +412,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://', ''));
|
||||
@@ -372,11 +435,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);
|
||||
});
|
||||
@@ -401,19 +463,17 @@ 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;
|
||||
let isAdmin = false;
|
||||
let serverSteamId = undefined; let isAdmin = false;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString());
|
||||
serverSteamId = payload.steamId;
|
||||
isAdmin = !!payload.isAdmin;
|
||||
serverSteamId = payload.steamId; isAdmin = !!payload.isAdmin;
|
||||
} catch (e) {}
|
||||
const current = store.get('serverConfig');
|
||||
store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
|
||||
@@ -436,7 +496,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();
|
||||
@@ -453,7 +527,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();
|
||||
@@ -523,64 +597,81 @@ ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBacke
|
||||
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('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName));
|
||||
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
|
||||
|
||||
ipcMain.handle('update-app-icon', (event, themeName: string) => {
|
||||
setAppIcon(themeName);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('switch-account', async (event, loginName: string) => {
|
||||
if (!loginName) return false;
|
||||
try {
|
||||
// PROACTIVE SYNC: Try to snag the freshest token before we kill Steam
|
||||
const accounts = store.get('accounts') as Account[];
|
||||
const account = accounts.find(a => a.loginName === loginName);
|
||||
if (account && !account._id.startsWith('shared_')) {
|
||||
const freshConfig = steamClient.extractAccountConfig(loginName);
|
||||
if (freshConfig) {
|
||||
account.loginConfig = freshConfig;
|
||||
account.sessionUpdatedAt = new Date().toISOString();
|
||||
if (backend) await backend.shareAccount(account);
|
||||
store.set('accounts', accounts);
|
||||
}
|
||||
}
|
||||
|
||||
await killSteam();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"';
|
||||
const commands = [
|
||||
`${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`,
|
||||
`${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`,
|
||||
`${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f`
|
||||
];
|
||||
await new Promise<void>((res, rej) => exec(commands.join(' && '), (e) => e ? rej(e) : res()));
|
||||
if (account && account.loginConfig) steamClient.injectAccountConfig(loginName, account.loginConfig);
|
||||
} else if (process.platform === 'linux') {
|
||||
await steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId);
|
||||
}
|
||||
startSteam();
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));
|
||||
|
||||
ipcMain.handle('open-steam-app-login', async () => {
|
||||
console.log('[SteamClient] Preparing for fresh login...');
|
||||
await killSteam();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Clear auto-login registry
|
||||
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') {
|
||||
// On Linux we can use the steamClient helper to set an empty user
|
||||
await steamClient.setAutoLoginUser("", undefined, "");
|
||||
}
|
||||
|
||||
const command = process.platform === 'win32' ? 'start steam://open/login' : 'xdg-open steam://open/login';
|
||||
exec(command);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('open-steam-login', async (event, expectedSteamId: string) => {
|
||||
// Use a unique partition per account to prevent session bleeding
|
||||
const partitionId = expectedSteamId ? `persist:steam-login-${expectedSteamId}` : 'persist:steam-login-new';
|
||||
const loginSession = session.fromPartition(partitionId);
|
||||
|
||||
// If adding a brand new account, explicitly clear previous trash
|
||||
if (!expectedSteamId) {
|
||||
console.log('[Auth] Clearing session for new account login...');
|
||||
await loginSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb'] });
|
||||
}
|
||||
|
||||
// If we have an existing cookie string for this account, pre-inject it
|
||||
if (!expectedSteamId) 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) {
|
||||
console.log(`[Auth] Pre-injecting existing cookies for ${account.personaName}...`);
|
||||
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: name,
|
||||
value: value,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: name.includes('Secure')
|
||||
});
|
||||
} catch (e) {}
|
||||
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',
|
||||
|
||||
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
|
||||
@@ -19,6 +20,7 @@ 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'),
|
||||
|
||||
|
||||
@@ -62,18 +62,22 @@ export class BackendService {
|
||||
loginName: account.loginName,
|
||||
steamLoginSecure: account.steamLoginSecure,
|
||||
loginConfig: account.loginConfig,
|
||||
sessionUpdatedAt: account.sessionUpdatedAt
|
||||
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}`);
|
||||
|
||||
@@ -29,20 +29,25 @@ export const scrapeCooldown = async (steamId: string, steamLoginSecure: string):
|
||||
|
||||
$('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 !== '') {
|
||||
// Steam uses 'GMT' which some JS engines don't parse well, replace with 'UTC'
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
|
||||
if (dateText && dateText !== '') {
|
||||
const cleanDateText = dateText.replace(' GMT', ' UTC');
|
||||
const parsed = new Date(cleanDateText);
|
||||
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
expirationDate = parsed;
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
// We want the newest expiration date found
|
||||
if (!expirationDate || parsed > (expirationDate as Date)) {
|
||||
expirationDate = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ultimate-ban-tracker-desktop",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ultimate-ban-tracker-desktop",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"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.3.0",
|
||||
"version": "1.3.2",
|
||||
"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": [
|
||||
|
||||
@@ -49,6 +49,7 @@ interface AccountsContextType {
|
||||
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>;
|
||||
@@ -114,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();
|
||||
@@ -194,7 +201,7 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
|
||||
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer,
|
||||
getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts,
|
||||
adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
|
||||
scrapeAccount, adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
|
||||
}}>
|
||||
{children}
|
||||
</AccountsContext.Provider>
|
||||
|
||||
@@ -376,11 +376,12 @@ const AccountRow: React.FC<{
|
||||
onSwitch: (login: string) => void,
|
||||
onAuth: () => void
|
||||
}> = ({ account, onDelete, onSwitch, onAuth }) => {
|
||||
const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, 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;
|
||||
@@ -404,6 +405,12 @@ const AccountRow: React.FC<{
|
||||
const [imgSrc, setImgSrc] = useState(avatarSrc);
|
||||
useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await scrapeAccount(account.steamId);
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const handleOpenShare = async () => {
|
||||
setIsShareOpen(true);
|
||||
try {
|
||||
@@ -522,7 +529,12 @@ const AccountRow: React.FC<{
|
||||
{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' }}>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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||