diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d9f4626..b013c57 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -179,6 +179,8 @@ export function registerIpc(): void { nativeTheme.shouldUseDarkColors ? 'dark' : 'light' ) + ipcMain.handle(IPC.getAppVersion, () => app.getVersion()) + ipcMain.handle(IPC.quit, () => app.quit()) ipcMain.handle(IPC.reminderClose, () => hideReminderWindow()) diff --git a/src/main/validate.ts b/src/main/validate.ts index b835933..2b9283d 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -303,6 +303,17 @@ export function validateSettingsPatch(raw: unknown): Partial | null { if (v === undefined) return null out.meetingAutoPause = v } + if ('lastSeenVersion' in raw) { + // Принимаем строку 0.0.0 .. 999.999.999 (semver-light) или null/undefined + // для сброса. + if (raw.lastSeenVersion === null || raw.lastSeenVersion === undefined) { + out.lastSeenVersion = undefined + } else { + const v = safeStr(raw.lastSeenVersion, 32) + if (v === undefined || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) return null + out.lastSeenVersion = v + } + } if ('notificationMode' in raw) { const v = oneOf(raw.notificationMode, VALID_NOTIFY) if (v === undefined) return null diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d548c8..10874cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -47,6 +47,7 @@ const api = { getAccentColor: (): Promise => ipcRenderer.invoke(IPC.getAccentColor), getOsTheme: (): Promise<'light' | 'dark'> => ipcRenderer.invoke(IPC.getOsTheme), + getAppVersion: (): Promise => ipcRenderer.invoke(IPC.getAppVersion), pauseAll: (): Promise => ipcRenderer.invoke(IPC.pauseAll), resumeAll: (): Promise => ipcRenderer.invoke(IPC.resumeAll), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0d50e04..907da1f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,6 +4,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { Sidebar } from './components/Sidebar' import { Titlebar } from './components/Titlebar' import { ErrorBoundary } from './components/ErrorBoundary' +import { WhatsNewModal } from './components/WhatsNewModal' +import { unseenVersions } from '@shared/release-notes' import Dashboard from './pages/Dashboard' import Exercises from './pages/Exercises' import GamesPage from './pages/Games' @@ -17,7 +19,12 @@ let backendSubscribed = false export default function App(): JSX.Element { const hydrated = useAppStore((s) => s.hydrated) + const settings = useAppStore((s) => s.state?.settings) const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [whatsNew, setWhatsNew] = useState<{ + open: boolean + versions: string[] + }>({ open: false, versions: [] }) useEffect(() => { if (backendSubscribed) return undefined @@ -29,6 +36,43 @@ export default function App(): JSX.Element { } }, []) + // После хидрации сверяем текущую версию приложения с lastSeenVersion. + // Если первая хидрация и lastSeenVersion ещё не записан — это либо + // первый запуск, либо обновление со старой версии (где поля не было) — + // в любом случае пишем текущую версию и НЕ показываем модалку (мы не + // хотим бить нового пользователя CHANGELOG'ом). + // Если lastSeenVersion есть и не совпадает с current → показываем. + useEffect(() => { + if (!hydrated || !settings) return + void window.api.getAppVersion().then((current) => { + const last = settings.lastSeenVersion + if (!last) { + // Первая запись — сохраняем тихо. + window.api.updateSettings({ lastSeenVersion: current }) + return + } + if (last !== current) { + const versions = unseenVersions(current, last) + if (versions.length > 0) { + setWhatsNew({ open: true, versions }) + } else { + // Версии есть, заметок нет — просто обновляем. + window.api.updateSettings({ lastSeenVersion: current }) + } + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + function closeWhatsNew(): void { + setWhatsNew({ open: false, versions: [] }) + // Записываем «видел» только после закрытия — если пользователь убил + // окно процессом до клика, при следующем запуске покажется снова. + void window.api.getAppVersion().then((current) => { + window.api.updateSettings({ lastSeenVersion: current }) + }) + } + return ( @@ -50,6 +94,11 @@ export default function App(): JSX.Element { )} + diff --git a/src/renderer/src/components/WhatsNewModal.tsx b/src/renderer/src/components/WhatsNewModal.tsx new file mode 100644 index 0000000..6c9f131 --- /dev/null +++ b/src/renderer/src/components/WhatsNewModal.tsx @@ -0,0 +1,108 @@ +import { useMemo } from 'react' +import { Sparkles, Wrench, Shield, Gauge } from 'lucide-react' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { useT } from '../i18n' +import { RELEASE_NOTES } from '@shared/release-notes' +import type { ReleaseNoteItem } from '@shared/release-notes' +import type { Language } from '@shared/types' + +type Props = { + open: boolean + versions: string[] + onClose: () => void +} + +const TAG_META = { + new: { icon: Sparkles, cls: 'bg-accent text-white' }, + fix: { icon: Wrench, cls: 'bg-info text-white' }, + security: { icon: Shield, cls: 'bg-warning text-white' }, + perf: { icon: Gauge, cls: 'bg-success text-white' } +} as const + +/** + * Показывает заметки релизов для одной или нескольких версий. Используется + * (a) автоматически после апдейта (когда `lastSeenVersion` != `currentVersion`) + * и (b) вручную из Settings. + */ +export function WhatsNewModal({ + open, + versions, + onClose +}: Props): JSX.Element { + const { t, lang } = useT() + return ( + {t('whatsnew.btn.close')}} + > +
+ {versions.length === 0 && ( +
+ {t('whatsnew.empty')} +
+ )} + {versions.map((v) => ( + + ))} +
+
+ ) +} + +function VersionSection({ + version, + lang +}: { + version: string + lang: Language +}): JSX.Element { + const items = useMemo(() => { + const notes = RELEASE_NOTES[version] + if (!notes) return [] + return notes[lang] ?? notes.ru + }, [version, lang]) + + return ( +
+
+ v{version} +
+
+ {items.map((it, i) => ( + + ))} +
+
+ ) +} + +function NoteRow({ item }: { item: ReleaseNoteItem }): JSX.Element { + const meta = TAG_META[item.tag ?? 'new'] + const IconCmp = meta.icon + return ( +
+
+ +
+
+
+ {item.title} +
+ {item.detail && ( +
+ {item.detail} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 77bde07..911f7d1 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -158,6 +158,13 @@ export const ru: Dict = { 'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?', 'settings.data.import.ok': 'Восстановлено', 'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?', + 'settings.section.about': 'О приложении', + 'settings.whatsnew.label': 'Что нового', + 'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.', + 'settings.whatsnew.btn': 'Открыть', + 'whatsnew.title': 'Что нового', + 'whatsnew.btn.close': 'Понятно', + 'whatsnew.empty': 'Для этой версии заметок пока нет.', 'settings.notification_mode.label': 'Режим уведомления', 'settings.notification_mode.hint': 'Как должно выглядеть напоминание', 'settings.notification_mode.modal': 'Окно поверх всех', @@ -466,6 +473,13 @@ export const en: Dict = { 'All current exercises, history and settings will be replaced with the file contents. Continue?', 'settings.data.import.ok': 'Restored', 'settings.data.import.err': "Couldn't read the file — not our backup?", + 'settings.section.about': 'About', + 'settings.whatsnew.label': "What's new", + 'settings.whatsnew.hint': 'See the latest release notes.', + 'settings.whatsnew.btn': 'Open', + 'whatsnew.title': "What's new", + 'whatsnew.btn.close': 'Got it', + 'whatsnew.empty': 'No notes available for this version yet.', 'settings.notification_mode.label': 'Notification mode', 'settings.notification_mode.hint': 'How a reminder appears', 'settings.notification_mode.modal': 'Window on top', diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index e68448f..9a4433f 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -3,6 +3,8 @@ import { useAppStore } from '../store/appStore' import { Switch } from '../components/ui/Switch' import { Card, Row, SectionHeader } from '../components/ui/Card' import { UpdaterCard } from '../components/UpdaterCard' +import { WhatsNewModal } from '../components/WhatsNewModal' +import { RELEASE_NOTES } from '@shared/release-notes' import { useT } from '../i18n' import type { Language, @@ -175,11 +177,53 @@ export default function SettingsPage(): JSX.Element { + +
+ + +
) } +function AboutCard(): JSX.Element { + const { t } = useT() + const [open, setOpen] = useState(false) + // Все версии для которых у нас есть заметки, отсортированы desc. + const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i] + return 0 + }) + return ( + + +
+
+ {t('settings.whatsnew.label')} +
+
+ {t('settings.whatsnew.hint')} +
+
+ +
+ setOpen(false)} + /> +
+ ) +} + function DataCard(): JSX.Element { const { t } = useT() const [busy, setBusy] = useState(false) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e03d9c0..b14f8a2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -11,6 +11,7 @@ export const IPC = { updateSettings: 'settings:update', getAccentColor: 'system:accentColor', getOsTheme: 'system:osTheme', + getAppVersion: 'system:appVersion', pauseAll: 'app:pauseAll', resumeAll: 'app:resumeAll', diff --git a/src/shared/release-notes.ts b/src/shared/release-notes.ts new file mode 100644 index 0000000..23b9beb --- /dev/null +++ b/src/shared/release-notes.ts @@ -0,0 +1,243 @@ +/** + * Заметки релизов для UI «Что нового». Хардкодные — синхронизируются + * с CHANGELOG.md руками при релизе. Не парсим .md в runtime: лишняя + * сложность, плюс отделение «коротких заметок для пользователя» от + * «развёрнутого технического CHANGELOG'а». + * + * Формат: версия → язык → массив пунктов (короткий заголовок + опц. деталь). + * Не более 5-7 пунктов на версию, иначе пользователь скроллит. + */ +import type { Language } from './types' + +export type ReleaseNoteItem = { + /** Лаконичная строка-заголовок (≤ 60 символов). */ + title: string + /** Опциональная одна строка-деталь (≤ 140 символов). */ + detail?: string + /** Категория для tint'а иконки. */ + tag?: 'new' | 'fix' | 'security' | 'perf' +} + +export type ReleaseNotes = Record + +export const RELEASE_NOTES: Record = { + '0.5.6': { + ru: [ + { + title: 'Категории напоминаний', + detail: + 'Помимо упражнений — гидратация, отдых глазам (20-20-20), осанка.', + tag: 'new' + }, + { + title: 'Голосовые подсказки', + detail: + 'Диктор произносит название упражнения и количество. Включается в настройках.', + tag: 'new' + }, + { + title: 'Достижения', + detail: + 'Milestones по количеству повторений и серий — с прогресс-баром до ближайшей цели.', + tag: 'new' + }, + { + title: 'Дневная цель', + detail: + 'Soft-cap на упражнение: набрал N повторов — реминдер умолкает до завтра.', + tag: 'new' + }, + { + title: 'Авто-пауза на ВКС', + detail: + 'Не дёргает напоминаниями, если запущен Zoom/Teams/Discord/Webex/Slack-huddle.', + tag: 'new' + }, + { + title: 'Адаптивный шедулер', + detail: + 'Учит часы, в которые ты честно делаешь упражнение, и сдвигает fire-ы туда.', + tag: 'new' + }, + { + title: 'Экспорт и импорт', + detail: + 'Резервная копия упражнений, истории и настроек в JSON — для переноса на другую машину.', + tag: 'new' + }, + { + title: 'Кнопка «Что нового»', + detail: 'Это окно. Открывается автоматически после обновления.', + tag: 'new' + } + ], + en: [ + { + title: 'Reminder categories', + detail: + 'Beyond exercises — hydration, eye rest (20-20-20), posture.', + tag: 'new' + }, + { + title: 'Voice prompts', + detail: + 'Speaks the exercise name and count. Toggle in Settings.', + tag: 'new' + }, + { + title: 'Achievements', + detail: + 'Milestones by total reps and streaks, with a progress bar to the next one.', + tag: 'new' + }, + { + title: 'Daily goal', + detail: + 'Soft cap per exercise: hit N reps and that reminder goes quiet until tomorrow.', + tag: 'new' + }, + { + title: 'Meeting auto-pause', + detail: + 'No reminders while Zoom/Teams/Discord/Webex/Slack-huddle is running.', + tag: 'new' + }, + { + title: 'Adaptive scheduling', + detail: + 'Learns the hours you reliably do an exercise and shifts fires into those windows.', + tag: 'new' + }, + { + title: 'Export & import', + detail: + 'JSON backup of exercises, history and settings — for moving to another machine.', + tag: 'new' + }, + { + title: "What's new screen", + detail: 'This screen. Opens automatically after an update.', + tag: 'new' + } + ] + }, + '0.5.5': { + ru: [ + { + title: 'Sandbox для окон', + detail: 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.', + tag: 'security' + }, + { + title: 'Self-hosted шрифты', + detail: + 'Plus Jakarta Sans + Bricolage Grotesque локально в bundle — работает без интернета.', + tag: 'security' + }, + { + title: 'Логи для диагностики', + detail: + '%APPDATA%/Exercise Reminder/logs/latest.log — отдашь файл при проблеме.', + tag: 'new' + }, + { + title: 'UI не залипает при retry-ях I/O', + tag: 'perf' + }, + { + title: 'GSI больше не зависает на TIME_WAIT при выходе', + tag: 'fix' + } + ], + en: [ + { + title: 'Window sandbox', + detail: 'OS-level isolation — even RCE in the renderer cannot reach main.', + tag: 'security' + }, + { + title: 'Self-hosted fonts', + detail: + 'Plus Jakarta Sans + Bricolage Grotesque shipped in-bundle — works offline.', + tag: 'security' + }, + { + title: 'Diagnostic logs', + detail: + '%APPDATA%/Exercise Reminder/logs/latest.log — share it when something breaks.', + tag: 'new' + }, + { + title: 'UI no longer freezes during I/O retries', + tag: 'perf' + }, + { + title: 'GSI port no longer stuck in TIME_WAIT after exit', + tag: 'fix' + } + ] + }, + '0.5.4': { + ru: [ + { + title: 'Фоновое скачивание апдейта', + detail: 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.', + tag: 'new' + }, + { + title: 'Моментальный рестарт', + detail: 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.', + tag: 'new' + } + ], + en: [ + { + title: 'Background update download', + detail: 'You can go to Dashboard and work — the update keeps downloading.', + tag: 'new' + }, + { + title: 'Instant restart', + detail: 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.', + tag: 'new' + } + ] + } +} + +/** + * Возвращает версии, отсортированные desc, для которых есть заметки, + * с версиями, не виденными пользователем. Используется для «show whatsnew + * after update»: если пользователь прыгнул через несколько версий, показываем + * все пропущенные одним списком. + */ +export function unseenVersions( + current: string, + lastSeen: string | undefined +): string[] { + const all = Object.keys(RELEASE_NOTES).sort(compareSemverDesc) + if (!lastSeen) { + // Первый запуск этого механизма — показываем только текущую версию + // чтобы не перегружать историей. Старые версии показывает только + // явный «What's new» из Settings. + return all.filter((v) => v === current) + } + return all.filter((v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0) +} + +function parseSemver(v: string): [number, number, number] { + const parts = v.split('.').map((n) => parseInt(n, 10)) + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0] +} + +function compareSemver(a: string, b: string): number { + const [a1, a2, a3] = parseSemver(a) + const [b1, b2, b3] = parseSemver(b) + if (a1 !== b1) return a1 - b1 + if (a2 !== b2) return a2 - b2 + return a3 - b3 +} + +function compareSemverDesc(a: string, b: string): number { + return -compareSemver(a, b) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f91b2e8..ec75552 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -82,6 +82,12 @@ export type Settings = { language: Language snoozeMinutes: number quietHours: QuietHours + /** + * Версия, для которой пользователь видел экран «Что нового». Если + * `app.getVersion()` отличается — модалка показывается при следующем + * запуске и записывает текущую версию. + */ + lastSeenVersion?: string } /**