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 { getExercises, getHistory, getSettings, updateExercise } from './store'
|
||||||
import { fireReminder } from './notifications'
|
import { fireReminder } from './notifications'
|
||||||
import { broadcastState } from './state-actions'
|
import { broadcastState } from './state-actions'
|
||||||
|
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
||||||
@@ -45,6 +46,15 @@ function checkDueExercises(): void {
|
|||||||
// CHECK_MS pass after the window ends will pick them up.
|
// CHECK_MS pass after the window ends will pick them up.
|
||||||
if (isQuietAt(settings.quietHours, new Date())) return
|
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 now = Date.now()
|
||||||
const exercises = getExercises()
|
const exercises = getExercises()
|
||||||
// history запрашивается только если хотя бы у одного упражнения есть
|
// history запрашивается только если хотя бы у одного упражнения есть
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
|
|||||||
if (v === undefined) return null
|
if (v === undefined) return null
|
||||||
out.voicePromptsEnabled = v
|
out.voicePromptsEnabled = v
|
||||||
}
|
}
|
||||||
|
if ('meetingAutoPause' in raw) {
|
||||||
|
const v = bool(raw.meetingAutoPause)
|
||||||
|
if (v === undefined) return null
|
||||||
|
out.meetingAutoPause = v
|
||||||
|
}
|
||||||
if ('notificationMode' in raw) {
|
if ('notificationMode' in raw) {
|
||||||
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
|
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
|
||||||
if (v === undefined) return null
|
if (v === undefined) return null
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export const ru: Dict = {
|
|||||||
'settings.voice.label': 'Голосовая подсказка',
|
'settings.voice.label': 'Голосовая подсказка',
|
||||||
'settings.voice.hint':
|
'settings.voice.hint':
|
||||||
'Диктор произносит название упражнения и количество — полезно когда фокус на коде.',
|
'Диктор произносит название упражнения и количество — полезно когда фокус на коде.',
|
||||||
|
'settings.meeting_pause.label': 'Пауза на встречах',
|
||||||
|
'settings.meeting_pause.hint':
|
||||||
|
'Не дёргать, если запущен Zoom / Teams / Discord / Webex / Slack-huddle.',
|
||||||
'settings.snooze.label': '«Отложить» на',
|
'settings.snooze.label': '«Отложить» на',
|
||||||
'settings.snooze.hint': 'Сколько минут добавлять при отложении',
|
'settings.snooze.hint': 'Сколько минут добавлять при отложении',
|
||||||
'settings.snooze.1': '1 минута',
|
'settings.snooze.1': '1 минута',
|
||||||
@@ -470,6 +473,9 @@ export const en: Dict = {
|
|||||||
'settings.voice.label': 'Voice prompt',
|
'settings.voice.label': 'Voice prompt',
|
||||||
'settings.voice.hint':
|
'settings.voice.hint':
|
||||||
'Speaks the exercise name and count — useful when your eyes are on the code.',
|
'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.label': '“Snooze” for',
|
||||||
'settings.snooze.hint': 'How many minutes to postpone',
|
'settings.snooze.hint': 'How many minutes to postpone',
|
||||||
'settings.snooze.1': '1 minute',
|
'settings.snooze.1': '1 minute',
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
checked={settings.voicePromptsEnabled}
|
checked={settings.voicePromptsEnabled}
|
||||||
onChange={(v) => patch({ voicePromptsEnabled: v })}
|
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
|
<SelectRow
|
||||||
label={t('settings.snooze.label')}
|
label={t('settings.snooze.label')}
|
||||||
hint={t('settings.snooze.hint')}
|
hint={t('settings.snooze.hint')}
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ export type Settings = {
|
|||||||
* пропустить, голос — нет.
|
* пропустить, голос — нет.
|
||||||
*/
|
*/
|
||||||
voicePromptsEnabled: boolean
|
voicePromptsEnabled: boolean
|
||||||
|
/**
|
||||||
|
* Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов
|
||||||
|
* (Zoom/Teams/Discord/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один
|
||||||
|
* запущен — fires не происходят. Чисто Windows (через tasklist).
|
||||||
|
*/
|
||||||
|
meetingAutoPause: boolean
|
||||||
startWithWindows: boolean
|
startWithWindows: boolean
|
||||||
minimizeToTray: boolean
|
minimizeToTray: boolean
|
||||||
startMinimized: boolean
|
startMinimized: boolean
|
||||||
@@ -207,6 +213,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
notificationMode: 'modal',
|
notificationMode: 'modal',
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
|
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
|
||||||
|
meetingAutoPause: true,
|
||||||
startWithWindows: false,
|
startWithWindows: false,
|
||||||
minimizeToTray: true,
|
minimizeToTray: true,
|
||||||
startMinimized: false,
|
startMinimized: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user