From 81481f21314e75411033d3000bf37bf11c7bb8b4 Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 13:48:42 +0700 Subject: [PATCH] =?UTF-8?q?feat(#5):=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BF?= =?UTF-8?q?=D0=B0=D1=83=D0=B7=D0=B0=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=BE=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F=20=D0=92=D0=9A=D0=A1=20(Zoom/Teams/Discord/W?= =?UTF-8?q?ebex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/meeting-detect.ts | 97 +++++++++++++++++++++++++++++ src/main/scheduler.ts | 10 +++ src/main/validate.ts | 5 ++ src/renderer/src/i18n/dict.ts | 6 ++ src/renderer/src/pages/Settings.tsx | 6 ++ src/shared/types.ts | 7 +++ 6 files changed, 131 insertions(+) create mode 100644 src/main/meeting-detect.ts diff --git a/src/main/meeting-detect.ts b/src/main/meeting-detect.ts new file mode 100644 index 0000000..6fc22c0 --- /dev/null +++ b/src/main/meeting-detect.ts @@ -0,0 +1,97 @@ +/** + * Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов. + * + * Идея: если запущен Zoom/Teams/Discord/Meet/Webex — пользователь скорее + * всего на встрече или собирается зайти. Останавливаем напоминания, чтобы + * не прерывать. После «снятия» процессов возобновляем. + * + * Проверка раз в 30 сек через `tasklist` (CSV-режим, без окна). Не используем + * audio capture API / mic state — это потребовало бы Windows-specific + * native-модуля; задача fit-for-purpose, не security-critical. + * + * False positive vs false negative tradeoff: лучше один лишний скип + * напоминания, чем один pop-up посреди митинга. Поэтому даже если + * процесс запущен, но митинга нет (Teams в фоне) — пауза. Пользователь + * это переживёт, обратное — не очень. + */ +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import { log } from './logger' + +const execAsync = promisify(exec) + +/** + * Имена процессов (Windows .exe). Регистр игнорируется при сравнении. + * Покрываем основные VOIP-приложения для индустрии WFH. + */ +const MEETING_PROCESSES = new Set([ + 'zoom.exe', + 'teams.exe', + 'ms-teams.exe', // новые Teams 2.0 + 'discord.exe', + 'webex.exe', + 'webexmta.exe', + 'meet.exe', // Google Meet desktop (редкость) + 'slack.exe', // huddle + 'skype.exe', + 'gotomeeting.exe', + 'whereby.exe' +]) + +let cachedActive = false +let lastCheckAt = 0 +const CACHE_MS = 30_000 + +/** + * Проверяет, запущен ли хотя бы один из meeting-процессов. Кеширует + * результат на CACHE_MS, чтобы tasklist не дёргался каждую секунду из + * scheduler-tick'а. + */ +export async function isMeetingActive(): Promise { + if (process.platform !== 'win32') return false + const now = Date.now() + if (now - lastCheckAt < CACHE_MS) return cachedActive + lastCheckAt = now + try { + // CSV без заголовков (/NH), скрытое окно. + const { stdout } = await execAsync('tasklist /FO CSV /NH', { + windowsHide: true, + maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой + }) + const lower = stdout.toLowerCase() + for (const proc of MEETING_PROCESSES) { + // Простой substring-match достаточен: формат CSV-row + // "zoom.exe","12345","Console","1","85,432 K". + if (lower.includes(`"${proc}",`)) { + if (!cachedActive) { + log.info(`[meeting] detected ${proc} — pausing reminders`) + } + cachedActive = true + return true + } + } + if (cachedActive) { + log.info('[meeting] no meeting processes — resuming reminders') + } + cachedActive = false + return false + } catch (e) { + // tasklist может фейлиться на нестандартных образах Windows. Лучше + // продолжить работу как обычно (не паузить), чем глушить напоминания + // навсегда. + log.warn('[meeting] tasklist failed, assuming no meeting', e) + cachedActive = false + return false + } +} + +/** Синхронный «последнее известное значение» без запроса. Используется + * в scheduler-tick'е — он не async. Background refresh идёт отдельно. */ +export function isMeetingActiveSync(): boolean { + return cachedActive +} + +/** Background refresh, дёргаем из scheduler'а. Не await'им. */ +export function refreshMeetingState(): void { + void isMeetingActive() +} diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index 18c1c2b..3654efb 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -5,6 +5,7 @@ import { isQuietAt } from '@shared/types' import { getExercises, getHistory, getSettings, updateExercise } from './store' import { fireReminder } from './notifications' import { broadcastState } from './state-actions' +import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect' /** * Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day). @@ -45,6 +46,15 @@ function checkDueExercises(): void { // CHECK_MS pass after the window ends will pick them up. if (isQuietAt(settings.quietHours, new Date())) return + // Авто-пауза на встречах. Sync-чтение кеша (последнее значение); refresh + // запускаем в фоне чтобы кеш «зрел» к следующему tick'у. На холодном + // старте кеш false — первое срабатывание может прийти в момент митинга, + // но 30 сек спустя система догонит и больше не будет fire'ить. + if (settings.meetingAutoPause) { + refreshMeetingState() + if (isMeetingActiveSync()) return + } + const now = Date.now() const exercises = getExercises() // history запрашивается только если хотя бы у одного упражнения есть diff --git a/src/main/validate.ts b/src/main/validate.ts index 3ed9d44..d414240 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -291,6 +291,11 @@ export function validateSettingsPatch(raw: unknown): Partial | null { if (v === undefined) return null out.voicePromptsEnabled = v } + if ('meetingAutoPause' in raw) { + const v = bool(raw.meetingAutoPause) + if (v === undefined) return null + out.meetingAutoPause = v + } if ('notificationMode' in raw) { const v = oneOf(raw.notificationMode, VALID_NOTIFY) if (v === undefined) return null diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index d279747..adb5079 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -168,6 +168,9 @@ export const ru: Dict = { 'settings.voice.label': 'Голосовая подсказка', 'settings.voice.hint': 'Диктор произносит название упражнения и количество — полезно когда фокус на коде.', + 'settings.meeting_pause.label': 'Пауза на встречах', + 'settings.meeting_pause.hint': + 'Не дёргать, если запущен Zoom / Teams / Discord / Webex / Slack-huddle.', 'settings.snooze.label': '«Отложить» на', 'settings.snooze.hint': 'Сколько минут добавлять при отложении', 'settings.snooze.1': '1 минута', @@ -470,6 +473,9 @@ export const en: Dict = { 'settings.voice.label': 'Voice prompt', 'settings.voice.hint': 'Speaks the exercise name and count — useful when your eyes are on the code.', + 'settings.meeting_pause.label': 'Pause during meetings', + 'settings.meeting_pause.hint': + 'Skip reminders when Zoom / Teams / Discord / Webex / Slack-huddle is running.', 'settings.snooze.label': '“Snooze” for', 'settings.snooze.hint': 'How many minutes to postpone', 'settings.snooze.1': '1 minute', diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index f4df79a..e68448f 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -83,6 +83,12 @@ export default function SettingsPage(): JSX.Element { checked={settings.voicePromptsEnabled} onChange={(v) => patch({ voicePromptsEnabled: v })} /> + patch({ meetingAutoPause: v })} + />