feat(#5): авто-пауза напоминаний во время ВКС (Zoom/Teams/Discord/Webex)
This commit is contained in:
97
src/main/meeting-detect.ts
Normal file
97
src/main/meeting-detect.ts
Normal file
@@ -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<boolean> {
|
||||
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()
|
||||
}
|
||||
@@ -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 запрашивается только если хотя бы у одного упражнения есть
|
||||
|
||||
@@ -291,6 +291,11 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -83,6 +83,12 @@ export default function SettingsPage(): JSX.Element {
|
||||
checked={settings.voicePromptsEnabled}
|
||||
onChange={(v) => patch({ voicePromptsEnabled: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('settings.meeting_pause.label')}
|
||||
hint={t('settings.meeting_pause.hint')}
|
||||
checked={settings.meetingAutoPause}
|
||||
onChange={(v) => patch({ meetingAutoPause: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label={t('settings.snooze.label')}
|
||||
hint={t('settings.snooze.hint')}
|
||||
|
||||
@@ -63,6 +63,12 @@ export type Settings = {
|
||||
* пропустить, голос — нет.
|
||||
*/
|
||||
voicePromptsEnabled: boolean
|
||||
/**
|
||||
* Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов
|
||||
* (Zoom/Teams/Discord/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один
|
||||
* запущен — fires не происходят. Чисто Windows (через tasklist).
|
||||
*/
|
||||
meetingAutoPause: boolean
|
||||
startWithWindows: boolean
|
||||
minimizeToTray: boolean
|
||||
startMinimized: boolean
|
||||
@@ -207,6 +213,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
notificationMode: 'modal',
|
||||
soundEnabled: true,
|
||||
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
|
||||
meetingAutoPause: true,
|
||||
startWithWindows: false,
|
||||
minimizeToTray: true,
|
||||
startMinimized: false,
|
||||
|
||||
Reference in New Issue
Block a user