From 9a4c4fa9435c679733f467d24ae58262026d96ea Mon Sep 17 00:00:00 2001 From: houseassassin Date: Tue, 2 Jun 2026 13:09:46 +0000 Subject: [PATCH] feat: icon extraction from .exe files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - electron/main.ts: extractExeIcon() via app.getFileIcon(), saves PNG to userData/icons/{md5}.png; IPC icon:extract; scanner:import now auto-extracts - electron/preload.ts: expose extractIcon() - AdminPanel: Scan button next to image field — extracts icon from current exe (shows animated pulse while loading, error if no exe selected or extraction fails) - GameCard: isExtractedIcon() detects icons/ path; renders centered 64x64 icon on gradient background instead of stretched cover Co-Authored-By: Claude Sonnet 4.6 --- electron/main.ts | 33 +++++++++++++++-- electron/preload.ts | 3 ++ package-lock.json | 4 +- package.json | 2 +- src/components/AdminPanel.tsx | 41 +++++++++++++++++++-- src/components/GameCard.tsx | 69 +++++++++++++++++++++++++---------- src/hooks/useGames.ts | 2 + 7 files changed, 124 insertions(+), 30 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 9456c2d..fc8c12a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,7 @@ import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron' import { join } from 'path' +import { existsSync, mkdirSync, writeFileSync } from 'fs' +import { createHash } from 'crypto' import { detectSteamGames, resolveSteamPath } from './steam' import { detectEpicGames } from './epic' import { @@ -133,12 +135,37 @@ ipcMain.handle('settings:remove-folder', (_e, folder: string) => { return updateSettings({ gameFolders: s.gameFolders.filter((f) => f !== folder) }) }) +// Icon extraction helper +async function extractExeIcon(exePath: string): Promise { + if (!existsSync(exePath)) return '' + try { + const nativeImage = await app.getFileIcon(exePath, { size: 'large' }) + if (nativeImage.isEmpty()) return '' + + const iconDir = join(app.getPath('userData'), 'icons') + if (!existsSync(iconDir)) mkdirSync(iconDir, { recursive: true }) + + const hash = createHash('md5').update(exePath).digest('hex') + const iconPath = join(iconDir, `${hash}.png`) + writeFileSync(iconPath, nativeImage.toPNG()) + return iconPath + } catch { + return '' + } +} + +// Icon IPC +ipcMain.handle('icon:extract', (_e, exePath: string) => extractExeIcon(exePath)) + // 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' }) +ipcMain.handle('scanner:import', async (_e, exes: Array<{ name: string; exe: string }>) => { + const imported = await Promise.all( + exes.map(async (e) => { + const image = await extractExeIcon(e.exe) + return addCustomGame({ name: e.name, exe: e.exe, args: [], image, category: 'Other' }) + }) ) return imported }) diff --git a/electron/preload.ts b/electron/preload.ts index f91fc28..8a7e63c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -30,6 +30,9 @@ const launcher = { addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-folder', folder) as Promise, removeGameFolder: (folder: string) => ipcRenderer.invoke('settings:remove-folder', folder) as Promise, + // Icon extraction + extractIcon: (exePath: string) => ipcRenderer.invoke('icon:extract', exePath) as Promise, + // Scanner scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise, importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise, diff --git a/package-lock.json b/package-lock.json index de8701c..4c5cfe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "club-launcher", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "club-launcher", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@node-steam/vdf": "^2.0.1", "lucide-react": "^0.441.0", diff --git a/package.json b/package.json index f2776fc..309aab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "club-launcher", - "version": "1.3.0", + "version": "1.3.1", "description": "Game launcher for computer club", "author": "houseassassin", "main": "out/main/main.js", diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 86ef8e0..bd6de11 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { X, Plus, Trash2, Edit2, FolderOpen, Image, Gamepad2, AppWindow } from 'lucide-react' +import { X, Plus, Trash2, Edit2, FolderOpen, Image, Gamepad2, AppWindow, Scan } from 'lucide-react' import type { AppEntry, CustomGame, Game, GameFormData } from '../types' const GAME_CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other'] @@ -75,8 +75,33 @@ export function AdminPanel({ setMode('edit') } - const pickExe = async () => { const p = await window.launcher.pickExe(); if (p) setForm((f) => ({ ...f, exe: p })) } - const pickImage = async () => { const p = await window.launcher.pickImage(); if (p) setForm((f) => ({ ...f, image: p })) } + const [extracting, setExtracting] = useState(false) + + const pickExe = async () => { + const p = await window.launcher.pickExe() + if (p) setForm((f) => ({ ...f, exe: p })) + } + + const pickImage = async () => { + const p = await window.launcher.pickImage() + if (p) setForm((f) => ({ ...f, image: p })) + } + + const extractIcon = async () => { + if (!form.exe.trim()) { setError('Сначала выберите исполняемый файл'); return } + setExtracting(true) + setError(null) + try { + const iconPath = await window.launcher.extractIcon(form.exe.trim()) + if (iconPath) { + setForm((f) => ({ ...f, image: iconPath })) + } else { + setError('Не удалось извлечь иконку из файла') + } + } finally { + setExtracting(false) + } + } const handleSave = async () => { if (!form.name.trim()) { setError('Введите название'); return } @@ -283,9 +308,17 @@ export function AdminPanel({ placeholder="C:\cover.jpg или https://..." className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors" /> - + {form.image && ( {/* Cover */} -
- {game.name} setImgError(true)} - loading="lazy" - /> +
+ {iconMode ? ( + // Extracted exe icon: show centered on gradient background +
+ {game.name} setImgError(true)} + loading="lazy" + /> +
+ ) : ( + {game.name} setImgError(true)} + loading="lazy" + /> + )}
{/* Badge */} diff --git a/src/hooks/useGames.ts b/src/hooks/useGames.ts index fafb2f7..71f39f6 100644 --- a/src/hooks/useGames.ts +++ b/src/hooks/useGames.ts @@ -26,6 +26,8 @@ declare global { setSteamPath: (path: string | null) => Promise addGameFolder: (folder: string) => Promise removeGameFolder: (folder: string) => Promise + // Icon extraction + extractIcon: (exePath: string) => Promise // Scanner scanFolder: (folder: string) => Promise importScanned: (exes: ScannedExe[]) => Promise