feat: initial Club Launcher MVP

This commit is contained in:
2026-05-30 19:27:24 +00:00
commit 2ef7e2a92d
27 changed files with 8139 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
import { join } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { detectSteamGames, SteamGame } from './steam'
import { readCustomGames, writeCustomGames, CustomGame } from './config'
import { launchSteamGame, launchExe } from './launcher'
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, // allow loading steam CDN images and local file:// images
},
})
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 Handlers ────────────────────────────────────────────────────────────
ipcMain.handle('games:get-all', async () => {
const [steamGames, customGames] = await Promise.all([
detectSteamGames(),
Promise.resolve(readCustomGames()),
])
return [...steamGames, ...customGames]
})
ipcMain.handle('games:launch', (_event, id: string) => {
if (id.startsWith('steam_')) {
const appid = id.replace('steam_', '')
launchSteamGame(appid)
return { ok: true }
}
const customs = readCustomGames()
const game = customs.find((g) => g.id === id)
if (!game) return { ok: false, error: 'Game not found' }
try {
launchExe(game.exe, game.args)
return { ok: true }
} catch (e) {
return { ok: false, error: String(e) }
}
})
ipcMain.handle('admin:add-game', (_event, game: Omit<CustomGame, 'id' | 'source'>) => {
const customs = readCustomGames()
const newGame: CustomGame = {
...game,
id: uuidv4(),
source: 'custom',
args: game.args ?? [],
image: game.image ?? '',
category: game.category ?? 'Other',
}
writeCustomGames([...customs, newGame])
return newGame
})
ipcMain.handle('admin:remove-game', (_event, id: string) => {
const customs = readCustomGames()
writeCustomGames(customs.filter((g) => g.id !== id))
return { ok: true }
})
ipcMain.handle('admin:update-game', (_event, updated: CustomGame) => {
const customs = readCustomGames()
writeCustomGames(customs.map((g) => (g.id === updated.id ? { ...g, ...updated } : g)))
return { ok: true }
})
ipcMain.handle('dialog:pick-exe', async () => {
const result = await dialog.showOpenDialog({
title: 'Выберите исполняемый файл',
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd'] }],
properties: ['openFile'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('dialog:pick-image', async () => {
const result = await dialog.showOpenDialog({
title: 'Выберите обложку',
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
properties: ['openFile'],
})
return result.canceled ? null : result.filePaths[0]
})
// ── App lifecycle ────────────────────────────────────────────────────────────
app.whenReady().then(() => {
createWindow()
// Ctrl+Alt+A → open admin panel
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()
})