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 { 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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[]>,
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 */}
|
||||||
|
|||||||
@@ -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[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user