diff --git a/src/main/games/steam-launch-options.ts b/src/main/games/steam-launch-options.ts index b0c4792..dc1e127 100644 --- a/src/main/games/steam-launch-options.ts +++ b/src/main/games/steam-launch-options.ts @@ -123,20 +123,19 @@ function writeBackup(path: string): void { } } -function atomicWrite(path: string, contents: string): void { +async function atomicWrite(path: string, contents: string): Promise { // Write to temp then rename (atomic on Windows for same directory). Retry a // few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes // hold a handle briefly during a Steam config rewrite). + // + // Раньше тут был busy-loop sleep — Steam-конфиги пишутся редко, но из main + // process, и при попадании на занятый файл (Steam ещё держит handle) морозили + // весь UI на 250мс. Заменили на async setTimeout-sleep. const tmp = path + '.exr.tmp' const delays = [0, 50, 200] let lastErr: unknown for (const delay of delays) { - if (delay > 0) { - const until = Date.now() + delay - while (Date.now() < until) { - /* spin */ - } - } + if (delay > 0) await new Promise((r) => setTimeout(r, delay)) try { writeFileSync(tmp, contents, 'utf-8') renameSync(tmp, path) @@ -148,11 +147,11 @@ function atomicWrite(path: string, contents: string): void { throw lastErr } -function modifyLaunchOptions( +async function modifyLaunchOptions( configPath: string, appId: string, fn: (current: string) => string | null -): boolean { +): Promise { let raw: string try { raw = readFileSync(configPath, 'utf-8') @@ -188,7 +187,7 @@ function modifyLaunchOptions( writeBackup(configPath) try { - atomicWrite(configPath, stringifyVdf(parsed)) + await atomicWrite(configPath, stringifyVdf(parsed)) } catch { return false } @@ -225,7 +224,7 @@ async function applyOptionToAllConfigs( ): Promise { const paths = await getLocalConfigPaths() for (const p of paths) { - modifyLaunchOptions(p, appId, (current) => { + await modifyLaunchOptions(p, appId, (current) => { if (current.includes(option)) return current return current.length > 0 ? `${current} ${option}` : option }) @@ -238,7 +237,7 @@ async function removeOptionFromAllConfigs( ): Promise { const paths = await getLocalConfigPaths() for (const p of paths) { - modifyLaunchOptions(p, appId, (current) => { + await modifyLaunchOptions(p, appId, (current) => { if (!current.includes(option)) return current return current .split(/\s+/) diff --git a/src/main/index.ts b/src/main/index.ts index 258e52a..217e306 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,11 +73,26 @@ if (!gotLock) { } }) - app.on('before-quit', () => { + // Перехватываем первый before-quit, чтобы дождаться `stopGamesRegistry` + // (закрывает GSI HTTP server со всеми pending connections). Без этого + // следующий запуск получает EADDRINUSE на port 4701 (TIME_WAIT), и + // GSI молча не работает. После cleanup'а — реально quit. + let quitting = false + app.on('before-quit', (e) => { + if (quitting) return + e.preventDefault() + quitting = true stopScheduler() stopUpdater() - void stopGamesRegistry() - flushNow() + void (async () => { + try { + await stopGamesRegistry() + } catch (err) { + console.error('[index] stopGamesRegistry threw:', err) + } + flushNow() + app.exit(0) + })() }) app.on('activate', () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7fb3190..6fbde3c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -56,9 +56,16 @@ import { export function registerIpc(): void { ipcMain.handle(IPC.getState, () => { + // Накладываем актуальное значение autostart (источник истины — OS), + // но НЕ мутируем кэш. Раньше прямая мутация state.settings оставляла + // в RAM startWithWindows, отличающийся от persisted-disk-значения, + // и при следующем flush на диск шла OS-правда, а не пользовательский + // toggle. Сейчас возвращаем поверхностную копию. const state = getState() - state.settings.startWithWindows = isAutostartEnabled() - return state + return { + ...state, + settings: { ...state.settings, startWithWindows: isAutostartEnabled() } + } }) ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { diff --git a/src/main/store.ts b/src/main/store.ts index b918ec5..cf3ed8a 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -166,7 +166,8 @@ function load(): AppState { const p = getStorePath() if (!existsSync(p)) { const initial = makeInitial() - atomicWrite( + // Cold path — sync write на инициализации (event-loop ещё не активен). + atomicWriteSync( p, JSON.stringify( { __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial }, @@ -235,8 +236,16 @@ export function clearHistory(beforeTs?: number): number { /** * Atomically write to `path` via a sibling .tmp file + rename. Retries a few * times on transient EBUSY/EPERM (AV/OneDrive holding the file). + * + * Async version (используется debounced scheduleWrite/flush) — раньше был + * busy-loop `while (Date.now() < until)`, который морозил весь main process + * на retry-delay (до 800мс). При активном AV это превращалось в видимое + * залипание UI. Сейчас sleep через setTimeout-promise. + * + * Для процесса-выхода используется `atomicWriteSync` — там event-loop уже + * не работает, async sleep не сработает. */ -function atomicWrite(path: string, contents: string): void { +async function atomicWrite(path: string, contents: string): Promise { const tmp = `${path}.tmp` let lastErr: unknown for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) { @@ -246,7 +255,6 @@ function atomicWrite(path: string, contents: string): void { return } catch (e) { lastErr = e - // best-effort cleanup of the stale .tmp try { if (existsSync(tmp)) unlinkSync(tmp) } catch { @@ -254,29 +262,63 @@ function atomicWrite(path: string, contents: string): void { } const delay = WRITE_RETRY_DELAYS[i] if (delay === undefined) break - // Synchronous sleep — write path is short and called outside the hot loop. + await new Promise((r) => setTimeout(r, delay)) + } + } + console.error('[store] atomic write failed after retries:', lastErr) +} + +/** + * Синхронный вариант для use-cases где event loop уже не работает + * (process exit в `before-quit`). При retry — короткий sync sleep, потому + * что иначе мы дропнем pending write при exit'е. + */ +function atomicWriteSync(path: string, contents: string): void { + const tmp = `${path}.tmp` + let lastErr: unknown + for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) { + try { + writeFileSync(tmp, contents, 'utf-8') + renameSync(tmp, path) + return + } catch (e) { + lastErr = e + try { + if (existsSync(tmp)) unlinkSync(tmp) + } catch { + /* ignore */ + } + const delay = WRITE_RETRY_DELAYS[i] + if (delay === undefined) break + // Event-loop остановлен, async sleep не вернётся — приходится spin. const until = Date.now() + delay while (Date.now() < until) { /* spin */ } } } - console.error('[store] atomic write failed after retries:', lastErr) + console.error('[store] atomic sync write failed after retries:', lastErr) } -function flush(): void { +async function flush(): Promise { if (!cache) return // Persist the schema version alongside the state so future migrations know // where to pick up from. The renderer never reads this key. const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache } - atomicWrite(getStorePath(), JSON.stringify(payload, null, 2)) + await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2)) +} + +function flushSync(): void { + if (!cache) return + const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache } + atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2)) } function scheduleWrite(): void { if (pendingWrite) return pendingWrite = setTimeout(() => { pendingWrite = null - flush() + void flush() }, WRITE_DEBOUNCE_MS) // Don't keep the event loop alive solely for a pending write — `before-quit` // calls `flushNow()` and we explicitly want the process to exit on schedule. @@ -389,7 +431,9 @@ export function flushNow(): void { clearTimeout(pendingWrite) pendingWrite = null } - flush() + // before-quit вызывает нас когда event-loop уже на пути к выходу — async + // promise не успеет resolved, поэтому sync. + flushSync() } export function getChallenges(): Challenge[] { diff --git a/src/renderer/src/lib/icon.tsx b/src/renderer/src/lib/icon.tsx index 1ad006d..3ac5d25 100644 --- a/src/renderer/src/lib/icon.tsx +++ b/src/renderer/src/lib/icon.tsx @@ -1,11 +1,51 @@ -import * as Lucide from 'lucide-react' +// Explicit-named imports — НЕ wildcard. Wildcard `* as Lucide` ломает +// tree-shaking: в bundle попадает вся библиотека (~500KB minified, 1500+ +// иконок). Сейчас в bundle только 18 ICON_CHOICES. +import { + Activity, + Dumbbell, + StretchHorizontal, + PersonStanding, + Heart, + Footprints, + Hand, + Eye, + Brain, + Bike, + Waves, + Wind, + Sun, + Coffee, + Apple, + GlassWater, + BookOpen, + Sparkles +} from 'lucide-react' import type { LucideProps } from 'lucide-react' import { ICON_CHOICES, type IconName } from './icon-choices' -// Re-export для обратной совместимости с импортёрами icon.tsx. export { ICON_CHOICES, type IconName } -const ICON_SET = new Set(ICON_CHOICES) +const ICON_MAP: Record> = { + Activity, + Dumbbell, + StretchHorizontal, + PersonStanding, + Heart, + Footprints, + Hand, + Eye, + Brain, + Bike, + Waves, + Wind, + Sun, + Coffee, + Apple, + GlassWater, + BookOpen, + Sparkles +} /** * Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set — @@ -17,15 +57,12 @@ export function Icon({ name, ...props }: { name: string } & LucideProps): JSX.Element { - if (!ICON_SET.has(name)) { + const Cmp = ICON_MAP[name as IconName] + if (!Cmp) { if (import.meta.env.DEV) { console.warn(`[Icon] unknown icon name "${name}" — falling back`) } - return + return } - const Cmp = ( - Lucide as unknown as Record> - )[name] - if (!Cmp) return return }