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 { 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<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
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
})
+3
View File
@@ -30,6 +30,9 @@ const launcher = {
addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-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
scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise<ScannedExe[]>,
importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise<CustomGame[]>,
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+37 -4
View File
@@ -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"
/>
<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} />
</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>
{form.image && (
<img
+49 -20
View File
@@ -44,18 +44,28 @@ function makeAppPlaceholder(name: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
function toFileUrl(path: string): string {
return `file://${path.replace(/\\/g, '/')}`
}
function getImageSrc(game: Game): string {
if (game.source === 'steam') return game.headerUrl
if (game.source === 'epic') return makePlaceholder(game.name)
if (game.source === 'steam') return game.headerUrl
if (game.source === 'epic') return makePlaceholder(game.name)
if (game.source === 'app') {
if (!game.image) return makeAppPlaceholder(game.name)
if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}`
if (!game.image) return makeAppPlaceholder(game.name)
if (game.image.startsWith('http')) return game.image
return toFileUrl(game.image)
}
// custom
if (!game.image) return makePlaceholder(game.name)
if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}`
if (!game.image) return makePlaceholder(game.name)
if (game.image.startsWith('http')) return game.image
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 ──────────────────────────────────────────────────────────
@@ -118,10 +128,13 @@ export function GameCard({
onToggleFavorite?.(game.id)
}
const badge = BADGE[game.source] ?? BADGE.custom
const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game)
const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]'
const playLabel = isApp ? 'Открыть' : 'Играть'
const badge = BADGE[game.source] ?? BADGE.custom
const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game)
const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]'
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 (
<div
@@ -139,14 +152,30 @@ export function GameCard({
onMouseLeave={handleMouseLeave}
>
{/* Cover */}
<div className={`${aspect} w-full overflow-hidden`}>
<img
src={imgSrc}
alt={game.name}
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-70"
onError={() => setImgError(true)}
loading="lazy"
/>
<div className={`${aspect} w-full overflow-hidden relative`}>
{iconMode ? (
// Extracted exe icon: show centered on gradient background
<div
className="w-full h-full flex items-center justify-center transition-all duration-200 group-hover:brightness-75"
style={{ background: `linear-gradient(135deg, ${bg1}, ${bg2})` }}
>
<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>
{/* Badge */}
+2
View File
@@ -26,6 +26,8 @@ declare global {
setSteamPath: (path: string | null) => Promise<AppSettings>
addGameFolder: (folder: string) => Promise<AppSettings>
removeGameFolder: (folder: string) => Promise<AppSettings>
// Icon extraction
extractIcon: (exePath: string) => Promise<string>
// Scanner
scanFolder: (folder: string) => Promise<ScannedExe[]>
importScanned: (exes: ScannedExe[]) => Promise<CustomGame[]>