112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
/**
|
||
* Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов.
|
||
*
|
||
* Идея: если запущен Zoom/Teams/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
|
||
'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 бывает большой
|
||
// Если 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()
|
||
}
|