176 lines
4.6 KiB
TypeScript
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
|
|
}
|