Files
laude/src/preload/index.ts
AnRil c5c05ee651 feat(updater): фоновое скачивание + моментальный рестарт
Раньше после «Скачать» renderer ждал promise (`ipcRenderer.invoke`),
пока electron-updater не завершит весь download. Если пользователь
закрывал Settings и уходил на Dashboard — скачивание продолжалось,
но кнопка возвращалась в `busy=true` при следующем открытии.
Сама установка через `quitAndInstall()` без параметров поднимала
NSIS-диалог установщика — ~5-10 сек до запуска новой версии.

Что изменилось:

- IPC `updaterDownload` / `updaterInstall` — fire-and-forget через
  `ipcMain.on` / `ipcRenderer.send`. Renderer триггерит и забывает,
  прогресс приходит через `evtUpdaterStatus`. UI моментально
  переключается в kind:'downloading' и не блокируется ожиданием.
- `autoUpdater.quitAndInstall(true, true)`:
    - isSilent=true: NSIS работает без UI установщика (~1-2 сек
      вместо ~5-10), без чёрного окна на половину экрана.
    - isForceRunAfter=true: гарантия что приложение запустится
      после установки (иначе пользователь нажал «Рестарт» и остался
      без открытого приложения).
- UpdaterCard: убран `busy` для async download — статус сам
  переключается через события. Добавлена подсказка «можно закрыть
  это окно, скачивание продолжится в фоне». Подкручен subtitle на
  downloaded-state: «нажми Рестарт — приложение моментально
  откроется в новой версии».
- i18n: новый ключ `updater.downloading.hint` (RU + EN), обновлён
  `updater.downloaded.subtitle`.

`autoInstallOnAppQuit = true` уже был включён — если пользователь
не нажал «Рестарт» и просто закрыл приложение, установка
произойдёт при следующем закрытии автоматически.
2026-05-19 21:33:00 +07:00

137 lines
5.5 KiB
TypeScript

import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '@shared/ipc'
import type {
AppState,
Challenge,
Exercise,
GameId,
GameStatus,
HistoryEntry,
MatchSummary,
Settings,
Tick,
UpdaterStatus
} from '@shared/types'
type Unsub = () => void
type Handler<T> = (payload: T) => void
function on<T>(channel: string, handler: Handler<T>): Unsub {
const listener = (_e: Electron.IpcRendererEvent, payload: T): void =>
handler(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
const api = {
getState: (): Promise<AppState> => ipcRenderer.invoke(IPC.getState),
addExercise: (
input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>
): Promise<Exercise> => ipcRenderer.invoke(IPC.addExercise, input),
updateExercise: (id: string, patch: Partial<Exercise>): Promise<Exercise> =>
ipcRenderer.invoke(IPC.updateExercise, id, patch),
deleteExercise: (id: string): Promise<boolean> =>
ipcRenderer.invoke(IPC.deleteExercise, id),
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
markDone: (id: string, actualReps?: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.markDone, id, actualReps),
snooze: (id: string, minutes: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.snooze, id, minutes),
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
updateSettings: (patch: Partial<Settings>): Promise<Settings> =>
ipcRenderer.invoke(IPC.updateSettings, patch),
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
quit: (): Promise<void> => ipcRenderer.invoke(IPC.quit),
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain),
isMaximizedMain: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.isMaximizedMain),
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
// Games
listGames: (): Promise<GameStatus[]> => ipcRenderer.invoke(IPC.gamesList),
installGame: (id: GameId): Promise<GameStatus> =>
ipcRenderer.invoke(IPC.gameInstall, id),
uninstallGame: (id: GameId): Promise<GameStatus> =>
ipcRenderer.invoke(IPC.gameUninstall, id),
toggleGame: (id: GameId, enabled: boolean): Promise<void> =>
ipcRenderer.invoke(IPC.gameToggle, id, enabled),
openGameLaunchOptions: (id: GameId): Promise<void> =>
ipcRenderer.invoke(IPC.gameOpenLaunchOptions, id),
// Challenges
addChallenge: (input: Omit<Challenge, 'id'>): Promise<Challenge> =>
ipcRenderer.invoke(IPC.addChallenge, input),
updateChallenge: (
id: string,
patch: Partial<Challenge>
): Promise<Challenge> => ipcRenderer.invoke(IPC.updateChallenge, id, patch),
deleteChallenge: (id: string): Promise<boolean> =>
ipcRenderer.invoke(IPC.deleteChallenge, id),
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
closeMatchSummary: (): Promise<void> =>
ipcRenderer.invoke(IPC.closeMatchSummary),
// Dev-only: synthesize a match-end event from the renderer. The channel is
// not registered in production builds (see src/main/ipc.ts), so this
// function will reject in shipped binaries even though it's exposed.
// Gated at the preload level too so the bundler can dead-code-eliminate it.
...(import.meta.env.MODE !== 'production'
? {
simulateMatchEnd: (
id: GameId,
stats: Record<string, number>
): Promise<void> =>
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats)
}
: {}),
// Auto-updater
updaterStatus: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterStatus),
updaterCheck: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterCheck),
// Fire-and-forget. Прогресс и завершение прилетают через onUpdaterStatus —
// renderer не должен `await`'ить, иначе busy-state висит весь download.
updaterDownload: (): void => ipcRenderer.send(IPC.updaterDownload),
updaterInstall: (): void => ipcRenderer.send(IPC.updaterInstall),
// History
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IPC.getHistory, sinceMs),
clearHistory: (beforeTs?: number): Promise<number> =>
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub =>
on(IPC.evtThemeChanged, h),
onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, h),
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
on(IPC.evtGamesChanged, h),
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h)
}
contextBridge.exposeInMainWorld('api', api)
export type Api = typeof api