diff --git a/.gitignore b/.gitignore index 3b3b45c..d7a4971 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist-electron/ dist-renderer/ out/ *.blockmap +dist/ diff --git a/electron/main.ts b/electron/main.ts index 4c468e8..9456c2d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,15 +1,16 @@ -import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron' +import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron' import { join } from 'path' -import { detectSteamGames } from './steam' +import { detectSteamGames, resolveSteamPath } from './steam' import { detectEpicGames } from './epic' import { CustomGame, AppEntry, - readCustomGames, writeCustomGames, - addCustomGame, removeCustomGame, updateCustomGame, + readCustomGames, addCustomGame, removeCustomGame, updateCustomGame, readApps, addApp, removeApp, updateApp, } from './config' import { launchSteamGame, launchEpicGame, launchExe } from './launcher' import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites' +import { readSettings, updateSettings } from './settings' +import { scanFolder } from './scanner' let mainWindow: BrowserWindow | null = null @@ -44,7 +45,8 @@ function createWindow(): void { // ── IPC ────────────────────────────────────────────────────────────────── ipcMain.handle('games:get-all', () => { - const steamGames = detectSteamGames() + const { steamPath } = readSettings() + const steamGames = detectSteamGames(steamPath) const epicGames = detectEpicGames() const customGames = readCustomGames() const apps = readApps() @@ -60,11 +62,9 @@ ipcMain.handle('games:launch', (_event, id: string) => { launchEpicGame(id.replace('epic_', '')) return { ok: true, recent: addRecent(id) } } - const all = [...readCustomGames(), ...readApps()] const game = all.find((g) => g.id === id) if (!game) return { ok: false, error: 'Not found' } - try { launchExe(game.exe, game.args) return { ok: true, recent: addRecent(id) } @@ -102,10 +102,51 @@ ipcMain.handle('dialog:pick-image', async () => { return r.canceled ? null : r.filePaths[0] }) +ipcMain.handle('dialog:pick-folder', async (_e, title = 'Выберите папку') => { + const r = await dialog.showOpenDialog({ title, properties: ['openDirectory'] }) + return r.canceled ? null : r.filePaths[0] +}) + +// Settings +ipcMain.handle('settings:get', () => { + const s = readSettings() + return { + ...s, + resolvedSteamPath: resolveSteamPath(s.steamPath), + } +}) + +ipcMain.handle('settings:set-steam-path', (_e, path: string | null) => { + return updateSettings({ steamPath: path }) +}) + +ipcMain.handle('settings:add-folder', (_e, folder: string) => { + const s = readSettings() + if (!s.gameFolders.includes(folder)) { + return updateSettings({ gameFolders: [...s.gameFolders, folder] }) + } + return s +}) + +ipcMain.handle('settings:remove-folder', (_e, folder: string) => { + const s = readSettings() + return updateSettings({ gameFolders: s.gameFolders.filter((f) => f !== folder) }) +}) + +// Scanner +ipcMain.handle('scanner:scan', (_e, folder: string) => scanFolder(folder)) + +ipcMain.handle('scanner:import', (_e, exes: Array<{ name: string; exe: string }>) => { + const imported = exes.map((e) => + addCustomGame({ name: e.name, exe: e.exe, args: [], image: '', category: 'Other' }) + ) + return imported +}) + // Favorites / recent -ipcMain.handle('favorites:get', () => getFavorites()) +ipcMain.handle('favorites:get', () => getFavorites()) ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id)) -ipcMain.handle('recent:get', () => getRecent()) +ipcMain.handle('recent:get', () => getRecent()) // ── App lifecycle ──────────────────────────────────────────────────────── diff --git a/electron/preload.ts b/electron/preload.ts index bcc7e2f..f91fc28 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { CustomGame, AppEntry } from './config' +import type { AppSettings } from './settings' +import type { ScannedExe } from './scanner' const launcher = { // Games @@ -8,23 +10,34 @@ const launcher = { ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>, // Custom games (admin) - addCustomGame: (g: Omit) => ipcRenderer.invoke('admin:add-game', g), - removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id), - updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g), + addCustomGame: (g: Omit) => ipcRenderer.invoke('admin:add-game', g), + removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id), + updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g), // App entries (admin) addApp: (a: Omit) => ipcRenderer.invoke('apps:add', a), - removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id), - updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a), + removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id), + updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a), // Dialogs - pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise, - pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise, + pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise, + pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise, + pickFolder: (title?: string) => ipcRenderer.invoke('dialog:pick-folder', title) as Promise, + + // Settings + getSettings: () => ipcRenderer.invoke('settings:get') as Promise, + setSteamPath: (path: string | null) => ipcRenderer.invoke('settings:set-steam-path', path) as Promise, + addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-folder', folder) as Promise, + removeGameFolder: (folder: string) => ipcRenderer.invoke('settings:remove-folder', folder) as Promise, + + // Scanner + scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise, + importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise, // Favorites / recent - getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise, - toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise, - getRecent: () => ipcRenderer.invoke('recent:get') as Promise, + getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise, + toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise, + getRecent: () => ipcRenderer.invoke('recent:get') as Promise, // Events onAdminOpen: (cb: () => void) => { diff --git a/electron/scanner.ts b/electron/scanner.ts new file mode 100644 index 0000000..5eed0bb --- /dev/null +++ b/electron/scanner.ts @@ -0,0 +1,96 @@ +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() + + 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 +} diff --git a/electron/settings.ts b/electron/settings.ts new file mode 100644 index 0000000..5f4d862 --- /dev/null +++ b/electron/settings.ts @@ -0,0 +1,42 @@ +import { app } from 'electron' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' + +export interface AppSettings { + steamPath: string | null + gameFolders: string[] +} + +const DEFAULTS: AppSettings = { + steamPath: null, + gameFolders: [], +} + +function settingsPath(): string { + return join(app.getPath('userData'), 'settings.json') +} + +export function readSettings(): AppSettings { + const path = settingsPath() + if (!existsSync(path)) return { ...DEFAULTS } + try { + const raw = JSON.parse(readFileSync(path, 'utf-8')) + return { + steamPath: typeof raw.steamPath === 'string' ? raw.steamPath : null, + gameFolders: Array.isArray(raw.gameFolders) ? raw.gameFolders.filter((f: unknown) => typeof f === 'string') : [], + } + } catch { + return { ...DEFAULTS } + } +} + +export function writeSettings(settings: AppSettings): void { + writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), 'utf-8') +} + +export function updateSettings(patch: Partial): AppSettings { + const current = readSettings() + const updated = { ...current, ...patch } + writeSettings(updated) + return updated +} diff --git a/electron/steam.ts b/electron/steam.ts index aeec579..6046cdd 100644 --- a/electron/steam.ts +++ b/electron/steam.ts @@ -163,8 +163,11 @@ function scanSteamApps(steamappsDir: string): SteamGame[] { return games } -export function detectSteamGames(): SteamGame[] { - const steamPath = getSteamPathFromRegistry() +export function detectSteamGames(customSteamPath?: string | null): SteamGame[] { + const steamPath = customSteamPath && existsSync(customSteamPath) + ? customSteamPath + : getSteamPathFromRegistry() + if (!steamPath) return [] const libraryFolders = getLibraryFolders(steamPath) @@ -177,3 +180,9 @@ export function detectSteamGames(): SteamGame[] { 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() +} diff --git a/package-lock.json b/package-lock.json index 9868f37..de8701c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "club-launcher", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "club-launcher", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@node-steam/vdf": "^2.0.1", "lucide-react": "^0.441.0", diff --git a/package.json b/package.json index d5e68c8..f2776fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "club-launcher", - "version": "1.2.0", + "version": "1.3.0", "description": "Game launcher for computer club", "author": "houseassassin", "main": "out/main/main.js", diff --git a/src/App.tsx b/src/App.tsx index 51aefc5..a830a00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA } from 'lucide-react' +import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA, SlidersHorizontal } from 'lucide-react' import { useGames } from './hooks/useGames' import { GameGrid } from './components/GameGrid' import { SearchBar } from './components/SearchBar' import { CategoryFilter } from './components/CategoryFilter' import { AdminPanel } from './components/AdminPanel' +import { SettingsPanel } from './components/SettingsPanel' import { ThemeToggle } from './components/ThemeToggle' import type { AppEntry, Category, CustomGame, Game, SortOrder } from './types' @@ -14,8 +15,9 @@ export default function App() { const [search, setSearch] = useState('') const [category, setCategory] = useState('all') const [sort, setSort] = useState('name-asc') - const [adminOpen, setAdminOpen] = useState(false) - const [adminMode, setAdminMode] = useState(false) + const [adminOpen, setAdminOpen] = useState(false) + const [adminMode, setAdminMode] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [favorites, setFavorites] = useState([]) const [recent, setRecent] = useState([]) @@ -161,10 +163,18 @@ export default function App() { + + @@ -189,6 +199,13 @@ export default function App() { {adminMode && ● Режим администратора} + {settingsOpen && ( + setSettingsOpen(false)} + onImported={reload} + /> + )} + {adminOpen && ( void + onImported: () => void +} + +export function SettingsPanel({ onClose, onImported }: Props) { + const [settings, setSettings] = useState(null) + const [visible, setVisible] = useState(false) + const [savingSteam, setSavingSteam] = useState(false) + + // Scanner state + const [scanning, setScanning] = useState(null) // folder being scanned + const [scanResults, setScanResults] = useState>({}) + const [selected, setSelected] = useState>>({}) + const [importing, setImporting] = useState(false) + const [imported, setImported] = useState>({}) + + useEffect(() => { + requestAnimationFrame(() => setVisible(true)) + window.launcher.getSettings().then(setSettings) + }, []) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + const handleClose = () => { + setVisible(false) + setTimeout(onClose, 280) + } + + // ── Steam path ───────────────────────────────────────────────────────── + + const pickSteamPath = async () => { + const path = await window.launcher.pickFolder('Укажите папку установки Steam') + if (!path || !settings) return + setSavingSteam(true) + const updated = await window.launcher.setSteamPath(path) + setSettings({ ...updated, resolvedSteamPath: path }) + setSavingSteam(false) + } + + const clearSteamPath = async () => { + if (!settings) return + const updated = await window.launcher.setSteamPath(null) + setSettings((s) => s ? { ...updated, resolvedSteamPath: s.resolvedSteamPath } : s) + } + + // ── Game folders ─────────────────────────────────────────────────────── + + const addFolder = async () => { + const folder = await window.launcher.pickFolder('Выберите папку с играми') + if (!folder) return + const updated = await window.launcher.addGameFolder(folder) + setSettings((s) => s ? { ...s, gameFolders: updated.gameFolders } : s) + } + + const removeFolder = async (folder: string) => { + const updated = await window.launcher.removeGameFolder(folder) + setSettings((s) => s ? { ...s, gameFolders: updated.gameFolders } : s) + setScanResults((r) => { const n = { ...r }; delete n[folder]; return n }) + setSelected((r) => { const n = { ...r }; delete n[folder]; return n }) + } + + const scanFolder = async (folder: string) => { + setScanning(folder) + try { + const exes = await window.launcher.scanFolder(folder) + setScanResults((r) => ({ ...r, [folder]: exes })) + // Pre-select all by default + setSelected((r) => ({ + ...r, + [folder]: new Set(exes.map((e) => e.exe)), + })) + } finally { + setScanning(null) + } + } + + const toggleExe = (folder: string, exe: string) => { + setSelected((r) => { + const set = new Set(r[folder] ?? []) + if (set.has(exe)) set.delete(exe); else set.add(exe) + return { ...r, [folder]: set } + }) + } + + const toggleAll = (folder: string) => { + const results = scanResults[folder] ?? [] + const sel = selected[folder] ?? new Set() + const allSelected = results.every((e) => sel.has(e.exe)) + setSelected((r) => ({ + ...r, + [folder]: allSelected ? new Set() : new Set(results.map((e) => e.exe)), + })) + } + + const importSelected = async (folder: string) => { + const results = scanResults[folder] ?? [] + const sel = selected[folder] ?? new Set() + const toImport = results.filter((e) => sel.has(e.exe)) + if (toImport.length === 0) return + + setImporting(true) + try { + await window.launcher.importScanned(toImport) + setImported((i) => ({ ...i, [folder]: toImport.length })) + onImported() + // Clear scan results for this folder after import + setTimeout(() => { + setScanResults((r) => { const n = { ...r }; delete n[folder]; return n }) + setSelected((r) => { const n = { ...r }; delete n[folder]; return n }) + setImported((r) => { const n = { ...r }; delete n[folder]; return n }) + }, 2000) + } finally { + setImporting(false) + } + } + + if (!settings) { + return ( +
+
+
+ ) + } + + return ( +
{ if (e.target === e.currentTarget) handleClose() }} + style={{ background: visible ? 'rgba(0,0,0,0.7)' : 'transparent', backdropFilter: visible ? 'blur(4px)' : 'none', transition: 'all 0.28s ease' }} + > +
+ {/* Header */} +
+
+

Настройки

+

Пути Steam и папки с играми

+
+ +
+ + {/* Scrollable content */} +
+ + {/* ── Steam section ─────────────────────────────────────────── */} +
+

+ S + Steam +

+ +
+

+ Автоопределённый путь: + {settings.resolvedSteamPath ?? не найден} +

+ + {settings.steamPath && ( +

+ Пользовательский путь: + {settings.steamPath} +

+ )} +
+ +
+ + + {settings.steamPath && ( + + )} +
+
+ + {/* ── Game folders section ───────────────────────────────────── */} +
+

+ + Папки с играми +

+ +

+ Добавьте папки (например D:\Games) — лаунчер просканирует их и предложит импортировать найденные игры. +

+ + {/* Folder list */} +
+ {settings.gameFolders.map((folder) => { + const results = scanResults[folder] + const sel = selected[folder] ?? new Set() + const isScanning = scanning === folder + const importedCount = imported[folder] + + return ( +
+ {/* Folder header */} +
+ +

{folder}

+ +
+ + {/* Scan controls */} +
+ + + {results && ( + + найдено: {results.length} + + )} + + {importedCount && ( + + Добавлено: {importedCount} + + )} +
+ + {/* Scan results */} + {results && results.length > 0 && ( +
+ {/* Select all */} + + + {/* List */} +
+ {results.map((exe) => ( + + ))} +
+ + {/* Import button */} + +
+ )} + + {results && results.length === 0 && ( +

Игры не найдены в этой папке

+ )} +
+ ) + })} +
+ + {/* Add folder button */} + +
+
+
+
+ ) +} diff --git a/src/hooks/useGames.ts b/src/hooks/useGames.ts index 1a02b50..fafb2f7 100644 --- a/src/hooks/useGames.ts +++ b/src/hooks/useGames.ts @@ -1,22 +1,34 @@ import { useState, useEffect, useCallback } from 'react' import type { AppEntry, CustomGame, Game } from '../types' +interface ScannedExe { name: string; exe: string } +interface AppSettings { steamPath: string | null; gameFolders: string[] } + declare global { interface Window { launcher: { - getGames: () => Promise + getGames: () => Promise launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }> // Custom games - addCustomGame: (g: Omit) => Promise - removeGame: (id: string) => Promise<{ ok: boolean }> - updateGame: (g: CustomGame) => Promise<{ ok: boolean }> + addCustomGame: (g: Omit) => Promise + removeGame: (id: string) => Promise<{ ok: boolean }> + updateGame: (g: CustomGame) => Promise<{ ok: boolean }> // App entries addApp: (a: Omit) => Promise removeApp: (id: string) => Promise<{ ok: boolean }> updateApp: (a: AppEntry) => Promise<{ ok: boolean }> // Dialogs - pickExe: () => Promise - pickImage: () => Promise + pickExe: () => Promise + pickImage: () => Promise + pickFolder: (title?: string) => Promise + // Settings + getSettings: () => Promise + setSteamPath: (path: string | null) => Promise + addGameFolder: (folder: string) => Promise + removeGameFolder: (folder: string) => Promise + // Scanner + scanFolder: (folder: string) => Promise + importScanned: (exes: ScannedExe[]) => Promise // Favorites / recent getFavorites: () => Promise toggleFavorite: (id: string) => Promise @@ -36,8 +48,7 @@ export function useGames() { setLoading(true) setError(null) try { - const data = await window.launcher.getGames() - setGames(data) + setGames(await window.launcher.getGames()) } catch (e) { setError(String(e)) } finally {