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>
97 lines
2.8 KiB
TypeScript
97 lines
2.8 KiB
TypeScript
import { existsSync, readdirSync, statSync } from 'fs'
|
|
import { join, basename, extname, dirname } from 'path'
|
|
|
|
export interface ScannedExe {
|
|
name: string
|
|
exe: string
|
|
}
|
|
|
|
// Known non-game executables to skip
|
|
const SKIP_PATTERNS = [
|
|
/unins/i, /uninstall/i, /setup/i, /install/i,
|
|
/update/i, /updater/i, /launcher_helper/i,
|
|
/crashpad/i, /crashreport/i, /crash_handler/i,
|
|
/redist/i, /vcredist/i, /dxsetup/i, /ue4prereq/i,
|
|
/helper/i, /bootstrap/i, /config/i,
|
|
/cefsharp/i, /steamwebhelper/i,
|
|
/^vc_redist/i, /^directx/i,
|
|
]
|
|
|
|
function shouldSkip(filename: string): boolean {
|
|
return SKIP_PATTERNS.some((p) => p.test(filename))
|
|
}
|
|
|
|
function deriveGameName(exePath: string, scanRoot: string): string {
|
|
const dir = dirname(exePath)
|
|
const folderName = basename(dir)
|
|
|
|
// If exe is directly in scan root, use filename without extension
|
|
if (dir === scanRoot) {
|
|
return basename(exePath, extname(exePath))
|
|
.replace(/[_-]/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
// Otherwise use folder name (usually the game name)
|
|
return folderName
|
|
.replace(/[_-]/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
/**
|
|
* Scans a directory 2 levels deep for .exe files.
|
|
* Returns candidate game executables, sorted by likelihood.
|
|
*/
|
|
export function scanFolder(folderPath: string): ScannedExe[] {
|
|
if (!existsSync(folderPath)) return []
|
|
|
|
const results: ScannedExe[] = []
|
|
const seen = new Set<string>()
|
|
|
|
function scanLevel(dir: string, depth: number): void {
|
|
let entries: string[]
|
|
try { entries = readdirSync(dir) } catch { return }
|
|
|
|
const exesInDir: string[] = []
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry)
|
|
|
|
try {
|
|
const stat = statSync(fullPath)
|
|
|
|
if (stat.isDirectory() && depth < 2) {
|
|
scanLevel(fullPath, depth + 1)
|
|
continue
|
|
}
|
|
|
|
if (stat.isFile() && extname(entry).toLowerCase() === '.exe') {
|
|
if (!shouldSkip(entry)) {
|
|
exesInDir.push(fullPath)
|
|
}
|
|
}
|
|
} catch {
|
|
// skip inaccessible
|
|
}
|
|
}
|
|
|
|
// If a folder has multiple exes, prefer the one matching the folder name
|
|
if (exesInDir.length > 1) {
|
|
const folderName = basename(dir).toLowerCase()
|
|
const preferred = exesInDir.find((p) => basename(p, '.exe').toLowerCase() === folderName)
|
|
const candidates = preferred ? [preferred] : exesInDir.slice(0, 1)
|
|
candidates.forEach((p) => {
|
|
if (!seen.has(p)) { seen.add(p); results.push({ name: deriveGameName(p, folderPath), exe: p }) }
|
|
})
|
|
} else {
|
|
exesInDir.forEach((p) => {
|
|
if (!seen.has(p)) { seen.add(p); results.push({ name: deriveGameName(p, folderPath), exe: p }) }
|
|
})
|
|
}
|
|
}
|
|
|
|
scanLevel(folderPath, 1)
|
|
results.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
|
return results
|
|
}
|