b84a7bdf29
- electron/settings.ts: persist steamPath + gameFolders in settings.json - electron/scanner.ts: scan folders 2 levels deep for .exe files, filter out known non-game binaries (uninstall/setup/redist/crash handlers) - electron/steam.ts: detectSteamGames(customPath?) — uses user path when set - electron/main.ts: new IPC handlers — dialog:pick-folder, settings:get/set-steam-path/ add-folder/remove-folder, scanner:scan/import - src/components/SettingsPanel.tsx: drawer UI — Steam path picker with auto-resolved path display, game folders list, per-folder scan with checkbox selection, one-click import of selected executables - src/App.tsx: SlidersHorizontal button opens SettingsPanel, reload on import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
5.0 KiB
TypeScript
189 lines
5.0 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(customSteamPath?: string | null): SteamGame[] {
|
|
const steamPath = customSteamPath && existsSync(customSteamPath)
|
|
? customSteamPath
|
|
: 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
|
|
}
|
|
|
|
/** Returns the currently resolved Steam path (for display in settings UI) */
|
|
export function resolveSteamPath(customPath?: string | null): string | null {
|
|
if (customPath && existsSync(customPath)) return customPath
|
|
return getSteamPathFromRegistry()
|
|
}
|