- src/shared/release-notes.ts — статический реестр заметок per-version (RU + EN), с тегами new/fix/security/perf для tint'а иконок. - Settings.lastSeenVersion — версия, для которой пользователь видел модалку. Валидатор регэксом /^\d+\.\d+\.\d+(-[\w.]+)?$/. - IPC.getAppVersion → app.getVersion() для renderer'а. - WhatsNewModal — список пунктов с цветовыми иконками. Footer-кнопка «Понятно» / «Got it». - App.tsx: после hydrate смотрит lastSeenVersion → current. Если расходятся и есть пропущенные заметки → автопоказ. На первой записи (lastSeenVersion === undefined) — тихо записываем, без модалки, чтобы не бить нового пользователя CHANGELOG'ом. - Settings → раздел «О приложении» → кнопка «Открыть» показывает модалку с заметками всех релизов.
357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
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<Exercise> = { 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<Settings> = { ...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<string, number>) => {
|
||
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) }
|
||
}
|
||
})
|
||
}
|