Files
club-launcher/electron/scanner.ts
T
houseassassin b84a7bdf29 feat: v1.3.0 — Steam path override, game folder scanner
- 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>
2026-06-02 13:05:29 +00:00

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
}