From 35d46f532a2499ebc4af513a4f6bc8608425517e Mon Sep 17 00:00:00 2001 From: houseassassin Date: Tue, 2 Jun 2026 12:28:52 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1.1.0=20=E2=80=94=20Epic=20Games=20det?= =?UTF-8?q?ection,=20favorites,=20recent,=20UI=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - electron/epic.ts: auto-detect Epic Games from Manifests/*.item (skip DLCs) - electron/favorites.ts: persist favorites + recent (last 20) in userData - electron/steam.ts: replace winreg callbacks with reg query execSync + fallback paths - electron: new IPC handlers — favorites:get/toggle, recent:get; launch tracks recent - UI: star button on cards (favorites), sort A→Z/Z→A toggle, skeleton loading, fade-in card animation, Epic/Favorites/Recent filter tabs, SVG placeholder with initials Co-Authored-By: Claude Sonnet 4.6 --- electron/epic.ts | 71 ++++++++++++++++++ electron/favorites.ts | 42 +++++++++++ electron/launcher.ts | 4 ++ electron/main.ts | 37 ++++++---- electron/preload.ts | 6 +- electron/steam.ts | 72 ++++++++++--------- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 116 +++++++++++++++++++++--------- src/components/AdminPanel.tsx | 5 +- src/components/CategoryFilter.tsx | 24 ++++--- src/components/GameCard.tsx | 97 ++++++++++++++++++------- src/components/GameGrid.tsx | 28 ++++++-- src/hooks/useGames.ts | 11 +-- src/types.ts | 15 +++- tailwind.config.ts | 14 ++++ 16 files changed, 414 insertions(+), 134 deletions(-) create mode 100644 electron/epic.ts create mode 100644 electron/favorites.ts diff --git a/electron/epic.ts b/electron/epic.ts new file mode 100644 index 0000000..edf34c9 --- /dev/null +++ b/electron/epic.ts @@ -0,0 +1,71 @@ +import { existsSync, readdirSync, readFileSync } from 'fs' +import { join } from 'path' + +export interface EpicGame { + id: string + name: string + appName: string + source: 'epic' + image: string + installed: true +} + +interface EpicManifest { + DisplayName?: string + AppName?: string + MainGameAppName?: string + bIsIncompleteInstall?: boolean + AppCategories?: string[] +} + +const MANIFESTS_DIR = 'C:\\ProgramData\\Epic\\EpicGamesLauncher\\Data\\Manifests' + +export function detectEpicGames(): EpicGame[] { + if (process.platform !== 'win32') return [] + if (!existsSync(MANIFESTS_DIR)) return [] + + let entries: string[] + try { + entries = readdirSync(MANIFESTS_DIR) + } catch { + return [] + } + + const games: EpicGame[] = [] + + for (const entry of entries) { + if (!entry.endsWith('.item')) continue + + try { + const raw = readFileSync(join(MANIFESTS_DIR, entry), 'utf-8') + const m: EpicManifest = JSON.parse(raw) + + const name = m.DisplayName + const appName = m.AppName + const mainApp = m.MainGameAppName + + if (!name || !appName) continue + if (m.bIsIncompleteInstall) continue + // Skip DLCs + if (mainApp && appName !== mainApp) continue + // Only game categories + const cats = m.AppCategories ?? [] + const isGame = cats.length === 0 || cats.some((c) => c.startsWith('games')) + if (!isGame) continue + + games.push({ + id: `epic_${appName}`, + name, + appName, + source: 'epic', + image: '', + installed: true, + }) + } catch { + // skip bad manifests + } + } + + games.sort((a, b) => a.name.localeCompare(b.name, 'ru')) + return games +} diff --git a/electron/favorites.ts b/electron/favorites.ts new file mode 100644 index 0000000..0493d0b --- /dev/null +++ b/electron/favorites.ts @@ -0,0 +1,42 @@ +import { app } from 'electron' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' + +function readJson(path: string, fallback: T): T { + if (!existsSync(path)) return fallback + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T + } catch { + return fallback + } +} + +function favPath(): string { + return join(app.getPath('userData'), 'favorites.json') +} + +function recentPath(): string { + return join(app.getPath('userData'), 'recent.json') +} + +export function getFavorites(): string[] { + return readJson(favPath(), []) +} + +export function toggleFavorite(id: string): string[] { + const favs = getFavorites() + const updated = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id] + writeFileSync(favPath(), JSON.stringify(updated), 'utf-8') + return updated +} + +export function getRecent(): string[] { + return readJson(recentPath(), []) +} + +export function addRecent(id: string): string[] { + const recent = getRecent().filter((r) => r !== id) + const updated = [id, ...recent].slice(0, 20) + writeFileSync(recentPath(), JSON.stringify(updated), 'utf-8') + return updated +} diff --git a/electron/launcher.ts b/electron/launcher.ts index 1911191..7c1bed4 100644 --- a/electron/launcher.ts +++ b/electron/launcher.ts @@ -5,6 +5,10 @@ export function launchSteamGame(appid: string): void { shell.openExternal(`steam://rungameid/${appid}`) } +export function launchEpicGame(appName: string): void { + shell.openExternal(`com.epicgames.launcher://apps/${appName}?action=launch&silent=true`) +} + export function launchExe(exe: string, args: string[] = []): void { const child = spawn(exe, args, { detached: true, diff --git a/electron/main.ts b/electron/main.ts index ccbaf5a..7cbae7d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,9 +1,11 @@ import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron' import { join } from 'path' import { v4 as uuidv4 } from 'uuid' -import { detectSteamGames, SteamGame } from './steam' +import { detectSteamGames } from './steam' +import { detectEpicGames } from './epic' import { readCustomGames, writeCustomGames, CustomGame } from './config' -import { launchSteamGame, launchExe } from './launcher' +import { launchSteamGame, launchEpicGame, launchExe } from './launcher' +import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites' let mainWindow: BrowserWindow | null = null @@ -21,7 +23,7 @@ function createWindow(): void { preload: join(__dirname, '../preload/preload.js'), contextIsolation: true, nodeIntegration: false, - webSecurity: false, // allow loading steam CDN images and local file:// images + webSecurity: false, }, }) @@ -39,19 +41,26 @@ function createWindow(): void { // ── IPC Handlers ──────────────────────────────────────────────────────────── -ipcMain.handle('games:get-all', async () => { - const [steamGames, customGames] = await Promise.all([ - detectSteamGames(), - Promise.resolve(readCustomGames()), - ]) - return [...steamGames, ...customGames] +ipcMain.handle('games:get-all', () => { + const steamGames = detectSteamGames() + const epicGames = detectEpicGames() + const customGames = readCustomGames() + return [...steamGames, ...epicGames, ...customGames] }) ipcMain.handle('games:launch', (_event, id: string) => { if (id.startsWith('steam_')) { const appid = id.replace('steam_', '') launchSteamGame(appid) - return { ok: true } + const recent = addRecent(id) + return { ok: true, recent } + } + + if (id.startsWith('epic_')) { + const appName = id.replace('epic_', '') + launchEpicGame(appName) + const recent = addRecent(id) + return { ok: true, recent } } const customs = readCustomGames() @@ -60,7 +69,8 @@ ipcMain.handle('games:launch', (_event, id: string) => { try { launchExe(game.exe, game.args) - return { ok: true } + const recent = addRecent(id) + return { ok: true, recent } } catch (e) { return { ok: false, error: String(e) } } @@ -110,12 +120,15 @@ ipcMain.handle('dialog:pick-image', async () => { return result.canceled ? null : result.filePaths[0] }) +ipcMain.handle('favorites:get', () => getFavorites()) +ipcMain.handle('favorites:toggle', (_event, id: string) => toggleFavorite(id)) +ipcMain.handle('recent:get', () => getRecent()) + // ── App lifecycle ──────────────────────────────────────────────────────────── app.whenReady().then(() => { createWindow() - // Ctrl+Alt+A → open admin panel globalShortcut.register('CommandOrControl+Alt+A', () => { mainWindow?.webContents.send('admin:open') }) diff --git a/electron/preload.ts b/electron/preload.ts index b6b958a..fb505e9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,7 +3,8 @@ import type { CustomGame } from './config' const launcher = { getGames: () => ipcRenderer.invoke('games:get-all'), - launchGame: (id: string) => ipcRenderer.invoke('games:launch', id), + launchGame: (id: string) => + ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>, addCustomGame: (game: Omit) => ipcRenderer.invoke('admin:add-game', game), removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id), updateGame: (game: CustomGame) => ipcRenderer.invoke('admin:update-game', game), @@ -13,6 +14,9 @@ const launcher = { ipcRenderer.on('admin:open', cb) return () => ipcRenderer.removeListener('admin:open', cb) }, + getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise, + toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise, + getRecent: () => ipcRenderer.invoke('recent:get') as Promise, } contextBridge.exposeInMainWorld('launcher', launcher) diff --git a/electron/steam.ts b/electron/steam.ts index 3c69474..aeec579 100644 --- a/electron/steam.ts +++ b/electron/steam.ts @@ -1,4 +1,5 @@ import { existsSync, readdirSync, readFileSync } from 'fs' +import { execSync } from 'child_process' import { join } from 'path' export interface SteamGame { @@ -52,36 +53,44 @@ function parseVdf(content: string): Record { return result } -async function getSteamPathFromRegistry(): Promise { +function getSteamPathFromRegistry(): 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', + // 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, }) - 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 + 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[] { @@ -154,12 +163,9 @@ function scanSteamApps(steamappsDir: string): SteamGame[] { return games } -export async function detectSteamGames(): Promise { - const steamPath = await getSteamPathFromRegistry() - if (!steamPath) { - // On non-Windows or Steam not installed, return empty - return [] - } +export function detectSteamGames(): SteamGame[] { + const steamPath = getSteamPathFromRegistry() + if (!steamPath) return [] const libraryFolders = getLibraryFolders(steamPath) const games: SteamGame[] = [] @@ -168,8 +174,6 @@ export async function detectSteamGames(): Promise { games.push(...scanSteamApps(folder)) } - // Sort alphabetically games.sort((a, b) => a.name.localeCompare(b.name, 'ru')) - return games } diff --git a/package-lock.json b/package-lock.json index 0b6353d..1ef56b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "club-launcher", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "club-launcher", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@node-steam/vdf": "^2.0.1", "lucide-react": "^0.441.0", diff --git a/package.json b/package.json index c19a204..db436ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "club-launcher", - "version": "1.0.0", + "version": "1.1.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 cc65a48..9b23b12 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,27 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Gamepad2, Settings, RefreshCw } from 'lucide-react' +import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA } 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 type { Category, CustomGame, Game } from './types' +import type { Category, CustomGame, Game, SortOrder } from './types' export default function App() { const { games, loading, reload } = useGames() 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 [favorites, setFavorites] = useState([]) + const [recent, setRecent] = useState([]) + + useEffect(() => { + window.launcher.getFavorites().then(setFavorites) + window.launcher.getRecent().then(setRecent) + }, []) - // Listen for Ctrl+Alt+A shortcut from main process useEffect(() => { const unsubscribe = window.launcher.onAdminOpen(() => { setAdminOpen(true) @@ -23,34 +30,62 @@ export default function App() { return unsubscribe }, []) - const filtered = useMemo(() => { - let list = games - if (category !== 'all') { - list = list.filter((g) => g.source === category) + const counts = useMemo((): Record => { + const recentSet = new Set(recent) + return { + all: games.length, + steam: games.filter((g) => g.source === 'steam').length, + epic: games.filter((g) => g.source === 'epic').length, + custom: games.filter((g) => g.source === 'custom').length, + favorites: games.filter((g) => favorites.includes(g.id)).length, + recent: games.filter((g) => recentSet.has(g.id)).length, } + }, [games, favorites, recent]) + + const filtered = useMemo(() => { + let list: Game[] + + if (category === 'favorites') { + list = games.filter((g) => favorites.includes(g.id)) + } else if (category === 'recent') { + const recentMap = new Map(recent.map((id, i) => [id, i])) + list = games.filter((g) => recentMap.has(g.id)) + list.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999)) + // Apply search but skip sort (recent order preserved) + if (search) { + const q = search.toLowerCase() + list = list.filter((g) => g.name.toLowerCase().includes(q)) + } + return list + } else if (category !== 'all') { + list = games.filter((g) => g.source === category) + } else { + list = [...games] + } + if (search) { const q = search.toLowerCase() list = list.filter((g) => g.name.toLowerCase().includes(q)) } - return list - }, [games, category, search]) - const counts = useMemo( - () => ({ - all: games.length, - steam: games.filter((g) => g.source === 'steam').length, - custom: games.filter((g) => g.source === 'custom').length, - }), - [games], - ) + list.sort((a, b) => + sort === 'name-asc' + ? a.name.localeCompare(b.name, 'ru') + : b.name.localeCompare(a.name, 'ru'), + ) + + return list + }, [games, category, search, sort, favorites, recent]) const handleLaunch = useCallback(async (id: string) => { - await window.launcher.launchGame(id) + const result = await window.launcher.launchGame(id) + if (result.recent) setRecent(result.recent) }, []) - const handleEditGame = useCallback((game: Game) => { - if (!adminOpen) setAdminOpen(true) - }, [adminOpen]) + const handleToggleFavorite = useCallback(async (id: string) => { + const updated = await window.launcher.toggleFavorite(id) + setFavorites(updated) + }, []) const handleAdd = useCallback(async (payload: Omit) => { await window.launcher.addCustomGame(payload) @@ -72,26 +107,41 @@ export default function App() { setAdminMode(false) } + const hasEpic = counts.epic > 0 + const hasFavorites = favorites.length > 0 + const hasRecent = recent.length > 0 + return (
{/* Header */} -
- {/* Logo */} -
+
+
Club Launcher
- {/* Category filter */} - + - {/* Spacer */}
- {/* Search */} - {/* Refresh */} + {/* Sort toggle */} + + - {/* Admin toggle */}
- {/* Game grid */} { if (!adminOpen) setAdminOpen(true) }} + onToggleFavorite={handleToggleFavorite} adminMode={adminMode} /> - {/* Status bar */}

{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} игр`} @@ -128,7 +177,6 @@ export default function App() {

- {/* Admin panel modal */} {adminOpen && ( )} - {/* Steam count info */} + {/* Auto-detected info */}

- Steam-игры ({games.filter((g) => g.source === 'steam').length}) определяются автоматически. + Steam ({games.filter((g) => g.source === 'steam').length}) и Epic ( + {games.filter((g) => g.source === 'epic').length}) определяются автоматически.

+ )} {/* Admin edit button */} {adminMode && onEdit && ( @@ -72,7 +117,7 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) { )} - {/* Hover overlay: game name + play button */} + {/* Hover overlay: play button */}

{game.name} @@ -87,7 +132,7 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {

- {/* Always-visible title at bottom (when not hovering) */} + {/* Always-visible title */}

{game.name}

diff --git a/src/components/GameGrid.tsx b/src/components/GameGrid.tsx index 9623392..b0dda4e 100644 --- a/src/components/GameGrid.tsx +++ b/src/components/GameGrid.tsx @@ -5,18 +5,29 @@ import type { Game } from '../types' interface Props { games: Game[] loading: boolean + favorites: string[] onLaunch: (id: string) => void onEdit: (game: Game) => void + onToggleFavorite: (id: string) => void adminMode: boolean } -export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props) { +function SkeletonCard() { + return ( +
+
+
+ ) +} + +export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, adminMode }: Props) { if (loading) { return ( -
-
-
-

Загрузка библиотеки...

+
+
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))}
) @@ -28,7 +39,7 @@ export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props)

Игры не найдены

-

Убедитесь что Steam установлен или добавьте игры вручную

+

Убедитесь что Steam/Epic установлены или добавьте игры вручную

) @@ -37,12 +48,15 @@ export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props) return (
- {games.map((game) => ( + {games.map((game, i) => ( ))} diff --git a/src/hooks/useGames.ts b/src/hooks/useGames.ts index 89ec4ef..9ae857c 100644 --- a/src/hooks/useGames.ts +++ b/src/hooks/useGames.ts @@ -1,17 +1,20 @@ import { useState, useEffect, useCallback } from 'react' -import type { Game } from '../types' +import type { CustomGame, Game } from '../types' declare global { interface Window { launcher: { getGames: () => Promise - launchGame: (id: string) => Promise<{ ok: boolean; error?: string }> - addCustomGame: (game: Omit) => Promise + launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }> + addCustomGame: (game: Omit) => Promise removeGame: (id: string) => Promise<{ ok: boolean }> - updateGame: (game: import('../types').CustomGame) => Promise<{ ok: boolean }> + updateGame: (game: CustomGame) => Promise<{ ok: boolean }> pickExe: () => Promise pickImage: () => Promise onAdminOpen: (cb: () => void) => () => void + getFavorites: () => Promise + toggleFavorite: (id: string) => Promise + getRecent: () => Promise } } } diff --git a/src/types.ts b/src/types.ts index db80520..5db37d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,15 @@ export interface SteamGame { installed: true } +export interface EpicGame { + id: string + name: string + appName: string + source: 'epic' + image: string + installed: true +} + export interface CustomGame { id: string name: string @@ -17,9 +26,11 @@ export interface CustomGame { source: 'custom' } -export type Game = SteamGame | CustomGame +export type Game = SteamGame | EpicGame | CustomGame -export type Category = 'all' | 'steam' | 'custom' +export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'favorites' | 'recent' + +export type SortOrder = 'name-asc' | 'name-desc' export interface GameFormData { name: string diff --git a/tailwind.config.ts b/tailwind.config.ts index 9840699..e3e7552 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -18,6 +18,20 @@ export default { aspectRatio: { steam: '460 / 215', }, + animation: { + 'fade-in': 'fadeIn 0.22s ease-out both', + shimmer: 'shimmer 1.4s ease-in-out infinite', + }, + keyframes: { + fadeIn: { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + shimmer: { + '0%, 100%': { opacity: '0.4' }, + '50%': { opacity: '0.8' }, + }, + }, }, }, plugins: [],