From 276d3bd4de603b1cdb073574e055e53a93e3514f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 04:27:55 +0100 Subject: [PATCH 1/5] fix: implement non-blocking two-phase sync and hardened GCPD scraper for reliable cooldown tracking --- frontend/electron/services/scraper.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/electron/services/scraper.ts b/frontend/electron/services/scraper.ts index 9619c82..e470573 100644 --- a/frontend/electron/services/scraper.ts +++ b/frontend/electron/services/scraper.ts @@ -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(); - - 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 as Date)) { + expirationDate = parsed; + } + } } - } + }); } }); From a5cc155ffc6483cbcffd30c151debb9c52b7726e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 04:31:50 +0100 Subject: [PATCH 2/5] fix: implement robust multi-timestamp synchronization logic to prevent data regression --- frontend/electron/main.ts | 27 +++++++++++++++++++++++---- frontend/electron/services/backend.ts | 10 +++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 7907be7..b9e5072 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -215,10 +215,10 @@ const scrapeAccountData = async (account: Account) => { 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); + if (backend) await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString()); } else { account.cooldownExpiresAt = undefined; - if (backend) await backend.pushCooldown(account.steamId, 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; @@ -267,10 +267,29 @@ const syncAccounts = async (isManual = false) => { exists.sessionUpdatedAt = s.sessionUpdatedAt; hasChanges = true; } - if (s.cooldownExpiresAt && (!exists.cooldownExpiresAt || new Date(s.cooldownExpiresAt) > new Date(exists.cooldownExpiresAt))) { - exists.cooldownExpiresAt = s.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; diff --git a/frontend/electron/services/backend.ts b/frontend/electron/services/backend.ts index 94786f8..82b8a7d 100644 --- a/frontend/electron/services/backend.ts +++ b/frontend/electron/services/backend.ts @@ -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}`); From d30005acbd6e0474a97d61aac4203b75a7e7f2a8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 04:33:43 +0100 Subject: [PATCH 3/5] fix: implement atomic writes and hardened VDF provisioning for robust Steam integration on fresh installs --- frontend/electron/services/steam-client.ts | 132 +++++++++++++-------- 1 file changed, 82 insertions(+), 50 deletions(-) diff --git a/frontend/electron/services/steam-client.ts b/frontend/electron/services/steam-client.ts index cfdc94e..79487a7 100644 --- a/frontend/electron/services/steam-client.ts +++ b/frontend/electron/services/steam-client.ts @@ -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,36 @@ class SteamClientService { return path.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. + */ + 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'); + + // Atomic rename + 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', () => { + console.log(`[SteamClient] loginusers.vdf changed, re-scanning...`); this.readLocalAccounts(); }); } @@ -71,16 +96,20 @@ class SteamClientService { try { const content = fs.readFileSync(filePath, 'utf-8'); + if (!content.trim()) return; // Empty file + 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, + personaName: user.PersonaName || user.AccountName, timestamp: parseInt(user.Timestamp) || 0 }); } @@ -98,35 +127,39 @@ class SteamClientService { 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]; - } + return (accounts && accounts[accountName]) ? accounts[accountName] : null; } catch (e) { console.error('[SteamClient] Failed to extract config.vdf data'); + return null; } - 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: {} } } } } }; + 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 + // Ensure safe nesting if (!data.InstallConfigStore) data.InstallConfigStore = {}; if (!data.InstallConfigStore.Software) data.InstallConfigStore.Software = {}; if (!data.InstallConfigStore.Software.Valve) data.InstallConfigStore.Software.Valve = {}; @@ -136,11 +169,9 @@ class SteamClientService { data.InstallConfigStore.Software.Valve.Steam.Accounts[accountName] = accountData; try { - fs.writeFileSync(configPath, stringify(data)); - console.log(`[SteamClient] Injected login config for ${accountName} into config.vdf`); - } catch (e) { - console.error('[SteamClient] Failed to write config.vdf'); - } + this.safeWriteVdf(configPath, data); + console.log(`[SteamClient] Safely injected session for ${accountName}`); + } catch (e) { } } public async setAutoLoginUser(accountName: string, accountConfig?: any, steamId?: string): Promise { @@ -148,14 +179,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 +193,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 +206,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 +222,15 @@ 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) { + 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,36 +238,40 @@ 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) => { + // Deep merge helper + 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]!]; + 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.writeFileSync(regPath, stringify(regData)); - console.log(`[SteamClient] Registry updated: ${regPath}`); + this.safeWriteVdf(regPath, regData); } catch (e) { } } } From 9174bcfca25e9e5ff1cd73bd28f43b0f4bffbaa6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 04:36:21 +0100 Subject: [PATCH 4/5] chore: bump version to 1.3.2 --- frontend/dist-electron/main.js | 57 ++++++-- frontend/dist-electron/services/backend.js | 10 +- frontend/dist-electron/services/scraper.js | 24 +-- .../dist-electron/services/steam-client.js | 138 +++++++++++------- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 6 files changed, 157 insertions(+), 78 deletions(-) diff --git a/frontend/dist-electron/main.js b/frontend/dist-electron/main.js index b3e288c..65c236f 100644 --- a/frontend/dist-electron/main.js +++ b/frontend/dist-electron/main.js @@ -60,18 +60,37 @@ 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; } } - if (!iconPath) + const possibleIcons = ['icon.png', 'icon.svg']; + let iconPath = ''; + if (assetsDir) { + for (const name of possibleIcons) { + const fullPath = path_1.default.join(assetsDir, name); + if (fs_1.default.existsSync(fullPath)) { + iconPath = fullPath; + break; + } + } + } + console.log(`[Tray] Resolved assets directory: ${assetsDir || 'NOT FOUND'}`); + console.log(`[Tray] Attempting to initialize with icon: ${iconPath || 'NONE FOUND'}`); + if (!iconPath) { + console.warn(`[Tray] FAILED: No valid icon found in searched paths.`); return; + } try { const icon = electron_1.nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); tray = new electron_1.Tray(icon); @@ -169,12 +188,12 @@ const scrapeAccountData = async (account) => { if (result.isActive) { account.cooldownExpiresAt = result.expiresAt ? result.expiresAt.toISOString() : new Date(Date.now() + 86400000).toISOString(); if (backend) - await backend.pushCooldown(account.steamId, account.cooldownExpiresAt); + await backend.pushCooldown(account.steamId, account.cooldownExpiresAt, now.toISOString()); } else { account.cooldownExpiresAt = undefined; if (backend) - await backend.pushCooldown(account.steamId, undefined); + await backend.pushCooldown(account.steamId, undefined, now.toISOString()); } } catch (e) { @@ -231,8 +250,24 @@ const syncAccounts = async (isManual = false) => { 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)) { diff --git a/frontend/dist-electron/services/backend.js b/frontend/dist-electron/services/backend.js index d7e23ba..5dbc56d 100644 --- a/frontend/dist-electron/services/backend.js +++ b/frontend/dist-electron/services/backend.js @@ -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) { diff --git a/frontend/dist-electron/services/scraper.js b/frontend/dist-electron/services/scraper.js index 9bbaaa7..ec3db93 100644 --- a/frontend/dist-electron/services/scraper.js +++ b/frontend/dist-electron/services/scraper.js @@ -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()) { diff --git a/frontend/dist-electron/services/steam-client.js b/frontend/dist-electron/services/steam-client.js index 61a97fb..a1f55e1 100644 --- a/frontend/dist-electron/services/steam-client.js +++ b/frontend/dist-electron/services/steam-client.js @@ -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) { } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd20399..985ed93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0ddea69..dd33e3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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 ", "homepage": "https://narl.io", "license": "SEE LICENSE IN LICENSE", From b64ddafab9c811d55ab97feed15f39c963a37dab Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 04:38:43 +0100 Subject: [PATCH 5/5] fix: implement proactive session capture and harden Steam registry/VDF injection for cross-machine reliability --- frontend/dist-electron/main.js | 50 +++++++++++++++++++++++++++++++++- frontend/electron/main.ts | 47 +++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/frontend/dist-electron/main.js b/frontend/dist-electron/main.js index 65c236f..4b529a1 100644 --- a/frontend/dist-electron/main.js +++ b/frontend/dist-electron/main.js @@ -345,11 +345,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() }); @@ -579,7 +588,46 @@ 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('switch-account', async (event, loginName) => { + if (!loginName) + return false; + try { + // PROACTIVE SYNC: Try to snag the freshest token before we kill Steam + const accounts = store.get('accounts'); + const account = accounts.find(a => a.loginName === loginName); + if (account && !account._id.startsWith('shared_')) { + const freshConfig = steam_client_1.steamClient.extractAccountConfig(loginName); + if (freshConfig) { + account.loginConfig = freshConfig; + account.sessionUpdatedAt = new Date().toISOString(); + if (backend) + await backend.shareAccount(account); + store.set('accounts', accounts); + } + } + await killSteam(); + if (process.platform === 'win32') { + const regBase = 'reg add "HKCU\\Software\\Valve\\Steam"'; + const commands = [ + `${regBase} /v AutoLoginUser /t REG_SZ /d "${loginName}" /f`, + `${regBase} /v RememberPassword /t REG_DWORD /d 1 /f`, + `${regBase} /v AlreadyLoggedIn /t REG_DWORD /d 1 /f`, + `${regBase} /v WantsOfflineMode /t REG_DWORD /d 0 /f` + ]; + await new Promise((res, rej) => (0, child_process_1.exec)(commands.join(' && '), (e) => e ? rej(e) : res())); + if (account && account.loginConfig) + steam_client_1.steamClient.injectAccountConfig(loginName, account.loginConfig); + } + else if (process.platform === 'linux') { + await steam_client_1.steamClient.setAutoLoginUser(loginName, account?.loginConfig, account?.steamId); + } + startSteam(); + return true; + } + catch (e) { + return false; + } +}); electron_1.ipcMain.handle('open-external', (event, url) => electron_1.shell.openExternal(url)); electron_1.ipcMain.handle('open-steam-app-login', async () => { await killSteam(); diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index b9e5072..8d3335c 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -356,11 +356,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() }); @@ -561,7 +571,42 @@ 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('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((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 () => {