Files
club-launcher/electron/steam.ts
T
houseassassin 35d46f532a feat: v1.1.0 — Epic Games detection, favorites, recent, UI improvements
- electron/epic.ts: auto-detect Epic Games from Manifests/*.item (skip DLCs)
- electron/favorites.ts: persist favorites + recent (last 20) in userData
- electron/steam.ts: replace winreg callbacks with reg query execSync + fallback paths
- electron: new IPC handlers — favorites:get/toggle, recent:get; launch tracks recent
- UI: star button on cards (favorites), sort A→Z/Z→A toggle, skeleton loading,
  fade-in card animation, Epic/Favorites/Recent filter tabs, SVG placeholder with initials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:28:52 +00:00

180 lines
4.7 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'fs'
import { execSync } from 'child_process'
import { join } from 'path'
export interface SteamGame {
id: string
name: string
appid: string
headerUrl: string
source: 'steam'
installed: true
}
function parseVdf(content: string): Record<string, unknown> {
// Minimal VDF parser for libraryfolders.vdf and appmanifest ACF files
const result: Record<string, unknown> = {}
const lines = content.split(/\r?\n/)
const stack: Array<Record<string, unknown>> = [result]
let key: string | null = null
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith('//')) continue
const tokenMatch = line.match(/^"([^"]*)"/)
if (!tokenMatch) {
if (line === '{') {
const obj: Record<string, unknown> = {}
if (key !== null) {
stack[stack.length - 1][key] = obj
}
stack.push(obj)
key = null
} else if (line === '}') {
stack.pop()
key = null
}
continue
}
if (key === null) {
key = tokenMatch[1]
} else {
// value
const valueMatch = line.match(/^"[^"]*"\s+"([^"]*)"/)
if (valueMatch) {
stack[stack.length - 1][key] = valueMatch[1]
}
key = null
}
}
return result
}
function getSteamPathFromRegistry(): string | null {
if (process.platform !== 'win32') return null
// Try reg.exe directly — simpler and reliable in packaged Electron apps
const regPaths = [
'HKLM\\SOFTWARE\\Wow6432Node\\Valve\\Steam',
'HKLM\\SOFTWARE\\Valve\\Steam',
'HKCU\\SOFTWARE\\Valve\\Steam',
]
for (const regPath of regPaths) {
try {
const out = execSync(`reg query "${regPath}" /v InstallPath`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000,
})
const match = out.match(/InstallPath\s+REG_SZ\s+(.+)/i)
if (match) {
const p = match[1].trim()
if (existsSync(p)) return p
}
} catch {
// try next
}
}
// Fallback: common default install locations
const defaults = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
join(process.env['LOCALAPPDATA'] ?? 'C:\\Users\\Public', 'Steam'),
]
for (const p of defaults) {
if (existsSync(join(p, 'steam.exe'))) return p
}
return null
}
function getLibraryFolders(steamPath: string): string[] {
const folders: string[] = [join(steamPath, 'steamapps')]
const vdfPath = join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!existsSync(vdfPath)) return folders
try {
const content = readFileSync(vdfPath, 'utf-8')
const parsed = parseVdf(content)
// Support both old (numbered keys) and new (nested "path") format
const root = (parsed['libraryfolders'] ?? parsed['LibraryFolders'] ?? parsed) as Record<string, unknown>
for (const [, val] of Object.entries(root)) {
if (typeof val === 'string' && existsSync(val)) {
folders.push(join(val, 'steamapps'))
} else if (typeof val === 'object' && val !== null) {
const nested = val as Record<string, unknown>
if (typeof nested['path'] === 'string' && existsSync(nested['path'])) {
folders.push(join(nested['path'], 'steamapps'))
}
}
}
} catch {
// ignore parse errors
}
return [...new Set(folders)]
}
function scanSteamApps(steamappsDir: string): SteamGame[] {
if (!existsSync(steamappsDir)) return []
const games: SteamGame[] = []
let entries: string[]
try {
entries = readdirSync(steamappsDir)
} catch {
return []
}
for (const entry of entries) {
if (!entry.startsWith('appmanifest_') || !entry.endsWith('.acf')) continue
try {
const content = readFileSync(join(steamappsDir, entry), 'utf-8')
const parsed = parseVdf(content)
const app = (parsed['AppState'] ?? parsed) as Record<string, unknown>
const appid = String(app['appid'] ?? '')
const name = String(app['name'] ?? '')
if (!appid || !name || name === 'Steamworks Common Redistributables') continue
games.push({
id: `steam_${appid}`,
name,
appid,
headerUrl: `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`,
source: 'steam',
installed: true,
})
} catch {
// skip bad manifests
}
}
return games
}
export function detectSteamGames(): SteamGame[] {
const steamPath = getSteamPathFromRegistry()
if (!steamPath) return []
const libraryFolders = getLibraryFolders(steamPath)
const games: SteamGame[] = []
for (const folder of libraryFolders) {
games.push(...scanSteamApps(folder))
}
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
return games
}