import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, dialog, shell } from 'electron' import { readFileSync, writeFileSync } from 'node:fs' import { IPC } from '@shared/ipc' import type { Exercise, GameId, Settings } from '@shared/types' import { addChallenge, addExercise, clearHistory, deleteChallenge, deleteExercise, exportState, getHistory, getState, getStateForRenderer, importState, markDone, setGameEnabled, skip, snooze, updateChallenge, updateExercise, updateSettings } from './store' import { broadcastState } from './state-actions' import { setAutostart, isAutostartEnabled } from './autostart' import { setPaused, forceCheck } from './scheduler' import { hideReminderWindow, getMainWindow } from './windows' import { refreshMenu } from './tray' import { broadcastGames, installGame, listGamesStatus, simulateMatchEnd, toggleGame, uninstallGame } from './games/registry' import { checkForUpdates, downloadUpdate, getUpdaterStatus, quitAndInstall } from './updater' import { validateActualReps, validateChallengeInput, validateChallengePatch, validateExerciseInput, validateExercisePatch, validateId, validateSettingsPatch, validateSnoozeMinutes } from './validate' export function registerIpc(): void { ipcMain.handle(IPC.getState, () => { // Без history (см. getStateForRenderer) и с актуальным значением // autostart из OS — мутацию делаем по копии, не по cache. const state = getStateForRenderer() state.settings = { ...state.settings, startWithWindows: isAutostartEnabled() } return state }) ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { const safe = validateExerciseInput(input) if (!safe) return null const ex = addExercise(safe) broadcastState() return ex }) ipcMain.handle( IPC.updateExercise, (_e, idRaw: unknown, patchRaw: unknown) => { const id = validateId(idRaw) const patch = validateExercisePatch(patchRaw) if (!id || !patch) return null const ex = updateExercise(id, patch) broadcastState() return ex } ) ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => { const id = validateId(idRaw) if (!id) return false const ok = deleteExercise(id) broadcastState() return ok }) ipcMain.handle( IPC.toggleExercise, (_e, idRaw: unknown, enabledRaw: unknown) => { const id = validateId(idRaw) if (!id || typeof enabledRaw !== 'boolean') return null const patch: Partial = { enabled: enabledRaw } if (enabledRaw) { const ex = getState().exercises.find((e) => e.id === id) if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 } const ex = updateExercise(id, patch) broadcastState() return ex } ) ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => { const id = validateId(idRaw) if (!id) return null const ex = markDone(id, validateActualReps(repsRaw)) broadcastState() return ex }) ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => { const id = validateId(idRaw) const minutes = validateSnoozeMinutes(minRaw) if (!id || minutes === null) return null const ex = snooze(id, minutes) broadcastState() return ex }) ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => { const id = validateId(idRaw) if (!id) return null const ex = skip(id) broadcastState() return ex }) ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => { const patch = validateSettingsPatch(patchRaw) if (!patch) return null if (patch.startWithWindows !== undefined) { setAutostart(patch.startWithWindows) } const merged: Partial = { ...patch } if (patch.startWithWindows !== undefined) { merged.startWithWindows = isAutostartEnabled() } const settings = updateSettings(merged) broadcastState() // Language change reflects in the tray menu next time it's opened. if (patch.language !== undefined) refreshMenu() return settings }) ipcMain.handle(IPC.pauseAll, () => { setPaused(true) refreshMenu() }) ipcMain.handle(IPC.resumeAll, () => { setPaused(false) forceCheck() refreshMenu() }) ipcMain.handle(IPC.getAccentColor, () => { try { return '#' + systemPreferences.getAccentColor() } catch { return '#5B8DEF' } }) ipcMain.handle(IPC.getOsTheme, () => nativeTheme.shouldUseDarkColors ? 'dark' : 'light' ) ipcMain.handle(IPC.getAppVersion, () => app.getVersion()) ipcMain.handle(IPC.quit, () => app.quit()) ipcMain.handle(IPC.reminderClose, () => hideReminderWindow()) ipcMain.on(IPC.minimizeMain, (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() }) ipcMain.on(IPC.toggleMaximizeMain, (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return if (win.isMaximized()) win.unmaximize() else win.maximize() }) ipcMain.handle(IPC.isMaximizedMain, (event) => { return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false }) ipcMain.on(IPC.closeMain, () => { const main = getMainWindow() if (!main) return if (getState().settings.minimizeToTray) main.hide() else main.close() }) ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide()) // Games ipcMain.handle(IPC.gamesList, async () => listGamesStatus()) ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => { const status = await installGame(id) setGameEnabled(id, true) await toggleGame(id, true) const all = await listGamesStatus() broadcastGames(all) broadcastState() return status }) ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => { const status = await uninstallGame(id) setGameEnabled(id, false) const all = await listGamesStatus() broadcastGames(all) broadcastState() return status }) ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => { setGameEnabled(id, enabled) await toggleGame(id, enabled) const all = await listGamesStatus() broadcastGames(all) broadcastState() }) ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => { // Opens Steam's library; user manually adds launch options. shell.openExternal('steam://nav/games/details/570') }) // Challenges ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => { const safe = validateChallengeInput(input) if (!safe) return null const c = addChallenge(safe) broadcastState() return c }) ipcMain.handle( IPC.updateChallenge, (_e, idRaw: unknown, patchRaw: unknown) => { const id = validateId(idRaw) const patch = validateChallengePatch(patchRaw) if (!id || !patch) return null const c = updateChallenge(id, patch) broadcastState() return c } ) ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => { const id = validateId(idRaw) if (!id) return false const ok = deleteChallenge(id) broadcastState() return ok }) ipcMain.handle( IPC.toggleChallenge, (_e, idRaw: unknown, enabledRaw: unknown) => { const id = validateId(idRaw) if (!id || typeof enabledRaw !== 'boolean') return null const c = updateChallenge(id, { enabled: enabledRaw }) broadcastState() return c } ) ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow()) // Dev helper: simulate a match end with given stats. NEVER registered in // packaged builds — a compromised renderer (XSS, malicious npm dep) could // otherwise fabricate arbitrary match-end events at will. if (!app.isPackaged) { ipcMain.handle( IPC.devSimulateMatchEnd, (_e, id: GameId, stats: Record) => { simulateMatchEnd(id, stats) } ) } // Auto-updater ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus()) ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) // download/install — fire-and-forget. Прогресс и завершение приходят в // renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer // только зря держал бы `busy=true` весь download (минуты на медленной сети). ipcMain.on(IPC.updaterDownload, () => { void downloadUpdate() }) ipcMain.on(IPC.updaterInstall, () => quitAndInstall()) // History ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => clearHistory(beforeTs) ) // Export / Import. Используем native save/open dialogs Electron'а — // renderer не получает прямого доступа к ФС. ipcMain.handle(IPC.exportState, async (event) => { const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const stamp = new Date() .toISOString() .replace(/[:T]/g, '-') .slice(0, 16) const defaultPath = `laude-backup-${stamp}.json` const result = await dialog.showSaveDialog(win!, { title: 'Сохранить резервную копию', defaultPath, filters: [{ name: 'JSON', extensions: ['json'] }] }) if (result.canceled || !result.filePath) return { ok: false, path: null } try { writeFileSync(result.filePath, exportState(), 'utf-8') return { ok: true, path: result.filePath } } catch (e) { return { ok: false, path: null, error: String(e) } } }) ipcMain.handle(IPC.importState, async (event) => { const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const result = await dialog.showOpenDialog(win!, { title: 'Восстановить из резервной копии', properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }] }) if (result.canceled || result.filePaths.length === 0) { return { ok: false } } try { const raw = readFileSync(result.filePaths[0], 'utf-8') const ok = importState(raw) if (ok) broadcastState() return { ok } } catch (e) { return { ok: false, error: String(e) } } }) }