feat: icon extraction from .exe files

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:09:46 +00:00
parent b84a7bdf29
commit 9a4c4fa943
7 changed files with 124 additions and 30 deletions
+30 -3
View File
@@ -1,5 +1,7 @@
import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron' import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron'
import { join } from 'path' import { join } from 'path'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'
import { detectSteamGames, resolveSteamPath } from './steam' import { detectSteamGames, resolveSteamPath } from './steam'
import { detectEpicGames } from './epic' import { detectEpicGames } from './epic'
import { import {
@@ -133,12 +135,37 @@ ipcMain.handle('settings:remove-folder', (_e, folder: string) => {
return updateSettings({ gameFolders: s.gameFolders.filter((f) => f !== folder) }) return updateSettings({ gameFolders: s.gameFolders.filter((f) => f !== folder) })
}) })
// Icon extraction helper
async function extractExeIcon(exePath: string): Promise<string> {
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 // Scanner
ipcMain.handle('scanner:scan', (_e, folder: string) => scanFolder(folder)) ipcMain.handle('scanner:scan', (_e, folder: string) => scanFolder(folder))
ipcMain.handle('scanner:import', (_e, exes: Array<{ name: string; exe: string }>) => { ipcMain.handle('scanner:import', async (_e, exes: Array<{ name: string; exe: string }>) => {
const imported = exes.map((e) => const imported = await Promise.all(
addCustomGame({ name: e.name, exe: e.exe, args: [], image: '', category: 'Other' }) exes.map(async (e) => {
const image = await extractExeIcon(e.exe)
return addCustomGame({ name: e.name, exe: e.exe, args: [], image, category: 'Other' })
})
) )
return imported return imported
}) })
+3
View File
@@ -30,6 +30,9 @@ const launcher = {
addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-folder', folder) as Promise<AppSettings>, addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-folder', folder) as Promise<AppSettings>,
removeGameFolder: (folder: string) => ipcRenderer.invoke('settings:remove-folder', folder) as Promise<AppSettings>, removeGameFolder: (folder: string) => ipcRenderer.invoke('settings:remove-folder', folder) as Promise<AppSettings>,
// Icon extraction
extractIcon: (exePath: string) => ipcRenderer.invoke('icon:extract', exePath) as Promise<string>,
// Scanner // Scanner
scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise<ScannedExe[]>, scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise<ScannedExe[]>,
importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise<CustomGame[]>, importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise<CustomGame[]>,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "club-launcher", "name": "club-launcher",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "club-launcher", "name": "club-launcher",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@node-steam/vdf": "^2.0.1", "@node-steam/vdf": "^2.0.1",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "club-launcher", "name": "club-launcher",
"version": "1.3.0", "version": "1.3.1",
"description": "Game launcher for computer club", "description": "Game launcher for computer club",
"author": "houseassassin", "author": "houseassassin",
"main": "out/main/main.js", "main": "out/main/main.js",
+37 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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' import type { AppEntry, CustomGame, Game, GameFormData } from '../types'
const GAME_CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other'] const GAME_CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
@@ -75,8 +75,33 @@ export function AdminPanel({
setMode('edit') setMode('edit')
} }
const pickExe = async () => { const p = await window.launcher.pickExe(); if (p) setForm((f) => ({ ...f, exe: p })) } const [extracting, setExtracting] = useState(false)
const pickImage = async () => { const p = await window.launcher.pickImage(); if (p) setForm((f) => ({ ...f, image: p })) }
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 () => { const handleSave = async () => {
if (!form.name.trim()) { setError('Введите название'); return } if (!form.name.trim()) { setError('Введите название'); return }
@@ -283,9 +308,17 @@ export function AdminPanel({
placeholder="C:\cover.jpg или https://..." 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" 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"
/> />
<button onClick={pickImage} className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors" title="Выбрать"> <button onClick={pickImage} className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors" title="Выбрать файл">
<Image size={15} /> <Image size={15} />
</button> </button>
<button
onClick={extractIcon}
disabled={extracting || !form.exe.trim()}
className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors disabled:opacity-40"
title="Извлечь иконку из EXE"
>
<Scan size={15} className={extracting ? 'animate-pulse' : ''} />
</button>
</div> </div>
{form.image && ( {form.image && (
<img <img
+49 -20
View File
@@ -44,18 +44,28 @@ function makeAppPlaceholder(name: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}` return `data:image/svg+xml,${encodeURIComponent(svg)}`
} }
function toFileUrl(path: string): string {
return `file://${path.replace(/\\/g, '/')}`
}
function getImageSrc(game: Game): string { function getImageSrc(game: Game): string {
if (game.source === 'steam') return game.headerUrl if (game.source === 'steam') return game.headerUrl
if (game.source === 'epic') return makePlaceholder(game.name) if (game.source === 'epic') return makePlaceholder(game.name)
if (game.source === 'app') { if (game.source === 'app') {
if (!game.image) return makeAppPlaceholder(game.name) if (!game.image) return makeAppPlaceholder(game.name)
if (game.image.startsWith('http')) return game.image if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}` return toFileUrl(game.image)
} }
// custom // custom
if (!game.image) return makePlaceholder(game.name) if (!game.image) return makePlaceholder(game.name)
if (game.image.startsWith('http')) return game.image if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}` return toFileUrl(game.image)
}
function isExtractedIcon(game: Game): boolean {
const img = (game as { image?: string }).image ?? ''
// Paths saved by extractExeIcon go through userData/icons/
return img.includes('icons') && img.endsWith('.png') && !img.startsWith('http')
} }
// ── Badge config ────────────────────────────────────────────────────────── // ── Badge config ──────────────────────────────────────────────────────────
@@ -118,10 +128,13 @@ export function GameCard({
onToggleFavorite?.(game.id) onToggleFavorite?.(game.id)
} }
const badge = BADGE[game.source] ?? BADGE.custom const badge = BADGE[game.source] ?? BADGE.custom
const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game) const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game)
const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]' const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]'
const playLabel = isApp ? 'Открыть' : 'Играть' const playLabel = isApp ? 'Открыть' : 'Играть'
const iconMode = !isApp && !imgError && isExtractedIcon(game)
// For icon mode: gradient pair for background
const [bg1, bg2] = GRADIENTS[nameHash(game.name) % GRADIENTS.length]
return ( return (
<div <div
@@ -139,14 +152,30 @@ export function GameCard({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
{/* Cover */} {/* Cover */}
<div className={`${aspect} w-full overflow-hidden`}> <div className={`${aspect} w-full overflow-hidden relative`}>
<img {iconMode ? (
src={imgSrc} // Extracted exe icon: show centered on gradient background
alt={game.name} <div
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-70" className="w-full h-full flex items-center justify-center transition-all duration-200 group-hover:brightness-75"
onError={() => setImgError(true)} style={{ background: `linear-gradient(135deg, ${bg1}, ${bg2})` }}
loading="lazy" >
/> <img
src={imgSrc}
alt={game.name}
className="w-16 h-16 object-contain drop-shadow-xl"
onError={() => setImgError(true)}
loading="lazy"
/>
</div>
) : (
<img
src={imgSrc}
alt={game.name}
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-75"
onError={() => setImgError(true)}
loading="lazy"
/>
)}
</div> </div>
{/* Badge */} {/* Badge */}
+2
View File
@@ -26,6 +26,8 @@ declare global {
setSteamPath: (path: string | null) => Promise<AppSettings> setSteamPath: (path: string | null) => Promise<AppSettings>
addGameFolder: (folder: string) => Promise<AppSettings> addGameFolder: (folder: string) => Promise<AppSettings>
removeGameFolder: (folder: string) => Promise<AppSettings> removeGameFolder: (folder: string) => Promise<AppSettings>
// Icon extraction
extractIcon: (exePath: string) => Promise<string>
// Scanner // Scanner
scanFolder: (folder: string) => Promise<ScannedExe[]> scanFolder: (folder: string) => Promise<ScannedExe[]>
importScanned: (exes: ScannedExe[]) => Promise<CustomGame[]> importScanned: (exes: ScannedExe[]) => Promise<CustomGame[]>