/** * Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов. * * Идея: если запущен 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 { BrowserWindow } from 'electron' import { IPC } from '@shared/ipc' import { log } from './logger' function broadcast(active: boolean): void { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send(IPC.evtMeetingChanged, active) } } 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 бывает большой // Если tasklist подвис (повреждённый WMI, загруженная система) — exec // сам прибьёт процесс и уйдёт в catch. Без таймаута зависшие child // накапливались бы при каждом refresh. timeout: 4000, killSignal: 'SIGKILL' }) 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`) broadcast(true) } cachedActive = true return true } } if (cachedActive) { log.info('[meeting] no meeting processes — resuming reminders') broadcast(false) } 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() }