diff --git a/src/main/ipc.ts b/src/main/ipc.ts index df8c1f2..99a3c19 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -352,12 +352,16 @@ export function registerIpc(): void { defaultPath, filters: [{ name: 'JSON', extensions: ['json'] }] }) - if (result.canceled || !result.filePath) return { ok: false, path: null } + // Cancel — это не ошибка. Возвращаем canceled=true чтобы UI мог + // ничего не показывать (без error toast). + if (result.canceled || !result.filePath) { + return { ok: false, canceled: true, path: null } + } try { writeFileSync(result.filePath, exportState(), 'utf-8') - return { ok: true, path: result.filePath } + return { ok: true, canceled: false, path: result.filePath } } catch (e) { - return { ok: false, path: null, error: String(e) } + return { ok: false, canceled: false, path: null, error: String(e) } } }) @@ -369,7 +373,7 @@ export function registerIpc(): void { filters: [{ name: 'JSON', extensions: ['json'] }] }) if (result.canceled || result.filePaths.length === 0) { - return { ok: false } + return { ok: false, canceled: true } } try { const raw = readFileSync(result.filePaths[0], 'utf-8') @@ -378,9 +382,9 @@ export function registerIpc(): void { broadcastState() broadcastHistoryChanged() } - return { ok } + return { ok, canceled: false } } catch (e) { - return { ok: false, error: String(e) } + return { ok: false, canceled: false, error: String(e) } } }) } diff --git a/src/main/validate.test.ts b/src/main/validate.test.ts index 41f7ade..0a3a326 100644 --- a/src/main/validate.test.ts +++ b/src/main/validate.test.ts @@ -266,6 +266,65 @@ describe('validateSettingsPatch', () => { expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull() }) + it('accepts voicePromptsEnabled boolean', () => { + expect(validateSettingsPatch({ voicePromptsEnabled: true })).toEqual({ + voicePromptsEnabled: true + }) + expect(validateSettingsPatch({ voicePromptsEnabled: false })).toEqual({ + voicePromptsEnabled: false + }) + }) + + it('rejects non-boolean voicePromptsEnabled in patch', () => { + expect(validateSettingsPatch({ voicePromptsEnabled: 'yes' })).toBeNull() + expect(validateSettingsPatch({ voicePromptsEnabled: 1 })).toBeNull() + }) + + it('accepts meetingAutoPause boolean', () => { + expect(validateSettingsPatch({ meetingAutoPause: true })).toEqual({ + meetingAutoPause: true + }) + }) + + it('rejects non-boolean meetingAutoPause', () => { + expect(validateSettingsPatch({ meetingAutoPause: 'yes' })).toBeNull() + }) + + describe('lastSeenVersion', () => { + it('accepts valid semver', () => { + const r = validateSettingsPatch({ lastSeenVersion: '0.5.7' }) + expect(r?.lastSeenVersion).toBe('0.5.7') + expect(validateSettingsPatch({ lastSeenVersion: '10.20.30' })).toEqual({ + lastSeenVersion: '10.20.30' + }) + }) + + it('accepts pre-release suffix', () => { + const r = validateSettingsPatch({ lastSeenVersion: '0.5.7-beta.1' }) + expect(r?.lastSeenVersion).toBe('0.5.7-beta.1') + }) + + it('treats null/undefined as reset to undefined', () => { + const r1 = validateSettingsPatch({ lastSeenVersion: null }) + expect(r1).toEqual({ lastSeenVersion: undefined }) + const r2 = validateSettingsPatch({ lastSeenVersion: undefined }) + // 'lastSeenVersion' is `in raw` even if undefined — both treated reset. + expect(r2).toEqual({ lastSeenVersion: undefined }) + }) + + it('rejects malformed strings', () => { + expect(validateSettingsPatch({ lastSeenVersion: '0.5' })).toBeNull() + expect(validateSettingsPatch({ lastSeenVersion: 'v0.5.7' })).toBeNull() + expect(validateSettingsPatch({ lastSeenVersion: 'beta' })).toBeNull() + expect(validateSettingsPatch({ lastSeenVersion: '' })).toBeNull() + }) + + it('rejects non-strings', () => { + expect(validateSettingsPatch({ lastSeenVersion: 42 })).toBeNull() + expect(validateSettingsPatch({ lastSeenVersion: ['1', '0', '0'] })).toBeNull() + }) + }) + describe('quietHours subobject', () => { const baseQh = { enabled: true, diff --git a/src/preload/index.ts b/src/preload/index.ts index 587e3da..1422656 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -122,10 +122,17 @@ const api = { ipcRenderer.invoke(IPC.clearHistory, beforeTs), // Export / Import — открывают native save/open dialogs из main process. - exportState: (): Promise<{ ok: boolean; path: string | null }> => - ipcRenderer.invoke(IPC.exportState), - importState: (): Promise<{ ok: boolean; error?: string }> => - ipcRenderer.invoke(IPC.importState), + 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): Unsub => on(IPC.evtTick, h), onFire: (h: Handler): Unsub => on(IPC.evtFire, h), diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 26b77ed..4150227 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -261,7 +261,8 @@ function DataCard(): JSX.Element { const r = await window.api.exportState() if (r.ok && r.path) { setToast(t('settings.data.export.ok', { path: r.path })) - } else if (!r.ok) { + } else if (!r.ok && !r.canceled) { + // canceled — пользователь сам передумал, тост не нужен. setToast(t('settings.data.export.err')) } } finally { @@ -275,7 +276,8 @@ function DataCard(): JSX.Element { try { const r = await window.api.importState() if (r.ok) setToast(t('settings.data.import.ok')) - else if ('error' in r && r.error !== undefined) { + else if (!r.canceled) { + // canceled — пользователь не выбрал файл, не показываем error. setToast(t('settings.data.import.err')) } } finally {