Files
laude/src/preload/index.ts
AnRil 2b7eb412c7 fix: export/import — отмена пользователем не показывает error toast
Bug: при отмене save-dialog или open-dialog DataCard показывал тост
«Не удалось сохранить» / «Файл не подошёл». Но cancel — это не ошибка.

Расширил IPC возврат: { ok, canceled, path?, error? }. UI теперь
различает: ok → success toast, !ok && canceled → молча, !ok && !canceled
→ error toast.

+9 тестов на validateSettingsPatch для voicePromptsEnabled,
meetingAutoPause, lastSeenVersion (semver-regex / null-сброс /
malformed). Итого 159 → 168 тестов.

Settings → About теперь показывает текущую версию приложения
(раньше была только кнопка «Что нового»). Загружается через
IPC.getAppVersion при mount.
2026-05-22 23:26:11 +07:00

159 lines
6.3 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),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.getMeetingActive),
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),
markChallengeDone: (id: string, reps: number): Promise<boolean> =>
ipcRenderer.invoke(IPC.markChallengeDone, id, reps),
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(IPC.devSimulateMatchEnd, 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),
// Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{
ok: boolean
canceled: boolean
path: string | null
error?: string
}> => ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{
ok: boolean
canceled: boolean
error?: string
}> => ipcRenderer.invoke(IPC.importState),
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),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h),
onHistoryChanged: (h: Handler<void>): Unsub =>
on(IPC.evtHistoryChanged, h)
}
contextBridge.exposeInMainWorld('api', api)
export type Api = typeof api