Files
club-launcher/electron/steam.ts
T

176 lines
4.6 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'fs'
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
}
async function getSteamPathFromRegistry(): Promise<string | null> {
if (process.platform !== 'win32') return null
try {
// Dynamic require to avoid issues on non-Windows
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Registry = require('winreg') as typeof import('winreg')
return new Promise((resolve) => {
const key = new Registry({
hive: Registry.HKLM,
key: '\\SOFTWARE\\Wow6432Node\\Valve\\Steam',
})
key.get('InstallPath', (err, item) => {
if (err || !item) {
// Try 32-bit key
const key32 = new Registry({
hive: Registry.HKLM,
key: '\\SOFTWARE\\Valve\\Steam',
})
key32.get('InstallPath', (err2, item2) => {
resolve(err2 || !item2 ? null : item2.value)
})
} else {
resolve(item.value)
}
})
})
} catch {
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 async function detectSteamGames(): Promise<SteamGame[]> {
const steamPath = await getSteamPathFromRegistry()
if (!steamPath) {
// On non-Windows or Steam not installed, return empty
return []
}
const libraryFolders = getLibraryFolders(steamPath)
const games: SteamGame[] = []
for (const folder of libraryFolders) {
games.push(...scanSteamApps(folder))
}
// Sort alphabetically
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
return games
}