Files
club-launcher/electron/main.ts
T
houseassassin 9a4c4fa943 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>
2026-06-02 13:09:46 +00:00

194 lines
6.6 KiB
TypeScript

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 {
CustomGame, AppEntry,
readCustomGames, addCustomGame, removeCustomGame, updateCustomGame,
readApps, addApp, removeApp, updateApp,
} from './config'
import { launchSteamGame, launchEpicGame, launchExe } from './launcher'
import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites'
import { readSettings, updateSettings } from './settings'
import { scanFolder } from './scanner'
let mainWindow: BrowserWindow | null = null
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
backgroundColor: '#0f0f0f',
title: 'Club Launcher',
frame: true,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false,
},
})
if (process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => { mainWindow = null })
}
// ── IPC ──────────────────────────────────────────────────────────────────
ipcMain.handle('games:get-all', () => {
const { steamPath } = readSettings()
const steamGames = detectSteamGames(steamPath)
const epicGames = detectEpicGames()
const customGames = readCustomGames()
const apps = readApps()
return [...steamGames, ...epicGames, ...customGames, ...apps]
})
ipcMain.handle('games:launch', (_event, id: string) => {
if (id.startsWith('steam_')) {
launchSteamGame(id.replace('steam_', ''))
return { ok: true, recent: addRecent(id) }
}
if (id.startsWith('epic_')) {
launchEpicGame(id.replace('epic_', ''))
return { ok: true, recent: addRecent(id) }
}
const all = [...readCustomGames(), ...readApps()]
const game = all.find((g) => g.id === id)
if (!game) return { ok: false, error: 'Not found' }
try {
launchExe(game.exe, game.args)
return { ok: true, recent: addRecent(id) }
} catch (e) {
return { ok: false, error: String(e) }
}
})
// Custom games
ipcMain.handle('admin:add-game', (_e, d: Omit<CustomGame, 'id' | 'source'>) => addCustomGame(d))
ipcMain.handle('admin:remove-game', (_e, id: string) => { removeCustomGame(id); return { ok: true } })
ipcMain.handle('admin:update-game', (_e, g: CustomGame) => { updateCustomGame(g); return { ok: true } })
// App entries
ipcMain.handle('apps:add', (_e, d: Omit<AppEntry, 'id' | 'source'>) => addApp(d))
ipcMain.handle('apps:remove', (_e, id: string) => { removeApp(id); return { ok: true } })
ipcMain.handle('apps:update', (_e, a: AppEntry) => { updateApp(a); return { ok: true } })
// Dialogs
ipcMain.handle('dialog:pick-exe', async () => {
const r = await dialog.showOpenDialog({
title: 'Выберите исполняемый файл',
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd', 'lnk'] }],
properties: ['openFile'],
})
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle('dialog:pick-image', async () => {
const r = await dialog.showOpenDialog({
title: 'Выберите обложку',
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'ico'] }],
properties: ['openFile'],
})
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle('dialog:pick-folder', async (_e, title = 'Выберите папку') => {
const r = await dialog.showOpenDialog({ title, properties: ['openDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
// Settings
ipcMain.handle('settings:get', () => {
const s = readSettings()
return {
...s,
resolvedSteamPath: resolveSteamPath(s.steamPath),
}
})
ipcMain.handle('settings:set-steam-path', (_e, path: string | null) => {
return updateSettings({ steamPath: path })
})
ipcMain.handle('settings:add-folder', (_e, folder: string) => {
const s = readSettings()
if (!s.gameFolders.includes(folder)) {
return updateSettings({ gameFolders: [...s.gameFolders, folder] })
}
return s
})
ipcMain.handle('settings:remove-folder', (_e, folder: string) => {
const s = readSettings()
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', 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
})
// Favorites / recent
ipcMain.handle('favorites:get', () => getFavorites())
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
ipcMain.handle('recent:get', () => getRecent())
// ── App lifecycle ────────────────────────────────────────────────────────
app.whenReady().then(() => {
createWindow()
globalShortcut.register('CommandOrControl+Alt+A', () => {
mainWindow?.webContents.send('admin:open')
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
globalShortcut.unregisterAll()
if (process.platform !== 'darwin') app.quit()
})