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:
+30
-3
@@ -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
|
||||
})
|
||||
|
||||
@@ -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[]>,
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 === 'app') {
|
||||
if (!game.image) return makeAppPlaceholder(game.name)
|
||||
if (game.image.startsWith('http')) return game.image
|
||||
return `file://${game.image.replace(/\\/g, '/')}`
|
||||
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, '/')}`
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
@@ -122,6 +132,9 @@ export function GameCard({
|
||||
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,15 +152,31 @@ export function GameCard({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className={`${aspect} w-full overflow-hidden`}>
|
||||
<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-full h-full object-cover transition-all duration-200 group-hover:brightness-70"
|
||||
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 */}
|
||||
<span className={`absolute top-2 left-2 px-2 py-0.5 text-[10px] font-semibold rounded-full select-none ${badge.bg} ${badge.text}`}>
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
Reference in New Issue
Block a user