diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.ts index 8917acdf..ea5bbdc4 100644 --- a/apps/x/apps/main/src/meeting-detect/probe-macos.ts +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.ts @@ -1,82 +1,20 @@ -import { spawn } from "node:child_process"; +import { execFileSync } from "node:child_process"; import type { MicProbe, MicUser } from "./types.js"; -// macOS doesn't expose a public "who is using the mic right now" API. Two -// pragmatic signals we can read from a shell without a native helper: -// -// 1. `pmset -g assertions` — apps in a video call almost always hold a -// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong -// proxy for "active call." False positives: video playback (YouTube, -// Netflix) — Phase 2's tab-title check filters those out for browsers. -// -// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and -// doesn't always include the mic user, so we prefer pmset as primary. -// -// Output format from `pmset -g assertions`: -// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..." -// pid 664(Google Chrome): [0x...] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections" -// -// We key on two assertion types: -// - PreventUserIdleDisplaySleep — native meeting apps keep the screen on -// during a call. We deliberately do NOT match PreventUserIdleSystemSleep, -// which is held by `caffeinate`, `powerd`, downloads, etc. (noise). -// - NoIdleSleepAssertion — browsers (Chrome/Arc/Safari/etc.) hold this with -// the reason "WebRTC has active PeerConnections" whenever a WebRTC call is -// live (Google Meet, Zoom web, Teams web, Discord, Slack huddles). This is -// the most reliable browser-meeting signal. False positives (e.g. a WebRTC -// YouTube tab) are filtered downstream by browser-match's tab-title check. const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdleDisplaySleep|NoIdleSleepAssertion)/; -const PMSET_TIMEOUT_MS = 10_000; +const PMSET_TIMEOUT_MS = 4_000; -// Run `pmset -g assertions` and resolve its stdout. -// -// We use spawn (not execFile) with stdin explicitly set to "ignore" because in -// a packaged .app launched from Finder — rather than a terminal — the main -// process has no valid stdin file descriptor. execFile would try to wire the -// child's stdio to that invalid fd, and since this runs repeatedly from the -// detector's background poll loop, the spawn fails with `EBADF` (errno -9). -// Setting stdio to ['ignore', 'pipe', 'pipe'] points the child's stdin at -// /dev/null, so no invalid descriptor is ever inherited. (This never surfaces -// in dev because launching from a terminal provides a valid stdin.) -function runPmsetAssertions(): Promise { - return new Promise((resolve, reject) => { - const child = spawn("/usr/bin/pmset", ["-g", "assertions"], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill("SIGKILL"); - reject(new Error(`pmset timed out after ${PMSET_TIMEOUT_MS}ms`)); - }, PMSET_TIMEOUT_MS); - - child.stdout?.on("data", (chunk) => { stdout += chunk; }); - child.stderr?.on("data", (chunk) => { stderr += chunk; }); - - child.on("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(err); - }); - - child.on("close", (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(`pmset exited with code ${code}: ${stderr.trim()}`)); - } - }); +// Sync execFileSync, NOT async execFile/spawn. In a Finder-launched packaged +// .app the async ChildProcess.spawn path fails with `spawn EBADF` (errno -9) +// every detector tick. The synchronous path avoids it and is already proven +// in this exact packaged app -- main.ts uses execFileSync at startup. +function runPmsetAssertions(): string { + return execFileSync("/usr/bin/pmset", ["-g", "assertions"], { + timeout: PMSET_TIMEOUT_MS, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); } @@ -84,21 +22,15 @@ export class MacOsMicProbe implements MicProbe { async probe(): Promise { let stdout: string; try { - stdout = await runPmsetAssertions(); + stdout = runPmsetAssertions(); } catch (err) { console.error("[MeetingDetect] macOS probe failed:", err); return []; } - return parseAssertions(stdout); } } -/** - * Parse `pmset -g assertions` stdout into the distinct processes holding a - * meeting-relevant assertion. Pure (no OS calls) so it's unit-testable against - * captured pmset output. One entry per pid; the first matching line wins. - */ export function parseAssertions(stdout: string): MicUser[] { const seen = new Map(); for (const line of stdout.split("\n")) {