fix: detect browser meetings on macOS, prevent duplicate popups (#562)

- probe-macos.ts: match NoIdleSleepAssertion (Chrome's WebRTC signal) so
  browser meetings are detected; tighten PreventUserIdle regex to drop
  caffeinate/powerd noise
- browser-match.ts: tighten Slack/Teams rules to require real in-call URLs;
  prioritize google-meet/zoom/teams/webex over slack-huddle when multiple
  tabs match
- suppression.ts: 90s per-app notify cooldown to stop repeat popups when
  the WebRTC assertion flickers mid-meeting

Tested live on macOS — single correctly-labeled popup confirmed.
This commit is contained in:
Prakhar Pandey 2026-06-12 01:30:40 +05:30
parent 5d8eecd3dc
commit af4cde329d
7 changed files with 289 additions and 42 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { matchTitleOrUrl } from "./browser-match.js";
import { matchTitleOrUrl, pickBestMatch } from "./browser-match.js";
describe("matchTitleOrUrl", () => {
it("matches Google Meet by URL", () => {
@ -22,8 +22,8 @@ describe("matchTitleOrUrl", () => {
expect(m?.platform).toBe("zoom-web");
});
it("matches Teams web", () => {
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
it("matches Teams web on a real meeting (meetup-join) URL", () => {
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc");
expect(m?.platform).toBe("teams-web");
});
@ -32,6 +32,22 @@ describe("matchTitleOrUrl", () => {
expect(m).toBeNull();
});
// Tightened rules — being open is not being in a call (issue #562 follow-up).
it("does NOT match a plain Slack tab (DM/channel open, no huddle)", () => {
const m = matchTitleOrUrl("Gagan (DM) - rowboat - Slack", "https://app.slack.com/client/T077R8M5U94/D0B77701AN7");
expect(m).toBeNull();
});
it("matches a Slack huddle by its title marker", () => {
const m = matchTitleOrUrl("Huddle in general - rowboat - Slack", "https://app.slack.com/client/T077/C123");
expect(m?.platform).toBe("slack-huddle");
});
it("does NOT match a Teams calendar/chat tab (no meetup-join)", () => {
const m = matchTitleOrUrl("Calendar | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
expect(m).toBeNull();
});
it("returns null for empty input", () => {
expect(matchTitleOrUrl(undefined, undefined)).toBeNull();
expect(matchTitleOrUrl("", "")).toBeNull();
@ -42,3 +58,54 @@ describe("matchTitleOrUrl", () => {
expect(m?.platform).toBe("zoom-web");
});
});
describe("pickBestMatch", () => {
// Verbatim tab set from the live session in issue #562: a Slack DM tab sat
// in front of the real Google Meet call. The old first-match logic labeled
// the popup "Slack huddle"; priority must now pick Google Meet — and the
// plain Slack tab must not match at all under the tightened rules.
const LIVE_TABS = [
"https://www.coursera.org/learn/dao-3022/lecture/3d0S8/benchmarking-evaluation-part-1",
"Benchmarking & Evaluation- Part 1 | Coursera",
"https://www.youtube.com/watch?v=qt2XslRMOto",
"(58) Inside India's Wealth Gap ... - YouTube",
"https://app.slack.com/client/T077R8M5U94/D0B77701AN7",
"Gagan (DM) - rowboat - Slack",
"https://github.com/rowboatlabs/rowboat/pull/562",
"feat: detect meeting joins ... · Pull Request #562",
"https://mail.google.com/mail/u/0/?tab=rm&ogbl#inbox",
"Inbox (4,067) - prakhar9999pandey@gmail.com - Gmail",
"https://meet.google.com/uaz-funz-pvy?authuser=0",
"Meet uaz-funz-pvy",
];
it("picks Google Meet over a backgrounded plain Slack tab (the #562 bug)", () => {
const m = pickBestMatch(LIVE_TABS);
expect(m?.platform).toBe("google-meet");
expect(m?.hint).toContain("meet.google.com/uaz-funz-pvy");
});
it("prioritizes google-meet over a genuine slack-huddle when both are open", () => {
const m = pickBestMatch([
"Huddle in general - rowboat - Slack",
"https://meet.google.com/abc-defg-hij",
]);
expect(m?.platform).toBe("google-meet");
});
it("still returns the slack-huddle when it is the only meeting tab", () => {
const m = pickBestMatch([
"Inbox - Gmail",
"Huddle in general - rowboat - Slack",
]);
expect(m?.platform).toBe("slack-huddle");
});
it("returns null when no tab is an actual meeting", () => {
expect(pickBestMatch([
"Gagan (DM) - rowboat - Slack",
"Calendar | Microsoft Teams",
"Inbox - Gmail",
])).toBeNull();
});
});

View file

@ -14,23 +14,39 @@ interface TitleRule {
needles: string[];
}
// Substrings we look for in the foreground window title (or URL when we
// have it). On Chrome/Edge/Firefox the page title is embedded in the window
// title, which is the most reliable cross-platform signal.
// Meet page title: "Meet - Daily Standup" → matches "meet -"
// Zoom web client: "Zoom Meeting" → matches "zoom meeting"
// Teams web: "<topic> | Microsoft Teams" → matches "microsoft teams"
// Substrings that indicate the user is ACTIVELY IN A CALL — not merely that the
// app happens to be open in a tab. Bare domains ("app.slack.com",
// "teams.microsoft.com") match any Slack DM or Teams calendar tab, so we require
// call-specific URL paths or title markers instead.
// Meet: meeting URLs are meet.google.com/<code>; title "Meet - <name>".
// Zoom: web client lives at zoom.us/j/<id> or zoom.us/wc/<id>.
// Teams: a live meeting join URL contains "meetup-join" (teams.microsoft.com
// or teams.live.com); the bare domain (calendar, chat) does not.
// Slack: a huddle shows "huddle" in the tab title; a plain Slack tab does not.
// Webex: meeting URLs contain webex.com/meet or /wbxmjs.
const RULES: TitleRule[] = [
{ platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] },
{ platform: "google-meet", needles: ["meet.google.com/", "google meet", "meet -", "meet —", "meet |"] },
{ platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] },
{ platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] },
{ platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] },
{ platform: "teams-web", needles: ["meetup-join", "teams.live.com/meet"] },
{ platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] },
{ platform: "slack-huddle", needles: ["huddle"] },
];
// When several tabs match different platforms (e.g. a Slack DM open behind the
// real Google Meet call), prefer the more definitive meeting. Dedicated meeting
// platforms outrank a Slack huddle, whose "huddle" title marker is the weakest
// signal. Lower index = higher precedence.
const PLATFORM_PRIORITY: BrowserMeetingPlatform[] = [
"google-meet",
"zoom-web",
"teams-web",
"webex-web",
"slack-huddle",
];
/**
* Look at the foreground window. If it's a browser and the title matches a
* known meeting URL/platform, return a match. Returns null otherwise.
* Look at the browser's open tabs/windows. If any matches a known meeting
* URL/platform, return the highest-priority match. Returns null otherwise.
*
* Caller is expected to only invoke this when the detector classified the
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap we
@ -39,13 +55,28 @@ const RULES: TitleRule[] = [
export async function matchBrowserMeeting(executable?: string): Promise<BrowserMeetingMatch | null> {
const snap = await getWindowSnapshot(executable);
if (!snap) return null;
// Scan ALL known window titles — on Windows tasklist returns every window,
// so even a backgrounded Meet tab still matches while Chrome holds the mic.
for (const title of snap.titles) {
return pickBestMatch(snap.titles);
}
/**
* Scan every tab title/URL, collect matches, and return the highest-priority
* one not just the first tab that matches. This prevents a backgrounded Slack
* DM from beating the real Google Meet call to the result. Pure; exposed for tests.
*/
export function pickBestMatch(titles: string[]): BrowserMeetingMatch | null {
let best: BrowserMeetingMatch | null = null;
let bestRank = Number.POSITIVE_INFINITY;
for (const title of titles) {
const m = matchTitleOrUrl(title, undefined);
if (m) return m;
if (!m) continue;
const rank = PLATFORM_PRIORITY.indexOf(m.platform);
if (rank < bestRank) {
best = m;
bestRank = rank;
if (rank === 0) break; // nothing outranks the top platform
}
}
return null;
return best;
}
/** Pure matcher — exposed for tests; no OS calls. */

View file

@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { parseAssertions } from "./probe-macos.js";
// Verbatim `pmset -g assertions` capture from a live macOS session (issue #562):
// Google Chrome is in a Google Meet call with the camera on, while caffeinate
// and powerd hold unrelated PreventUserIdleSystemSleep locks. The browser holds
// a NoIdleSleepAssertion ("WebRTC has active PeerConnections") — the regex must
// match that and ignore the System-sleep noise.
const PMSET_WEBRTC_CALL = `2026-06-11 22:59:21 +0530
Assertion status system-wide:
BackgroundTask 0
ApplePushServiceTask 0
UserIsActive 1
PreventUserIdleDisplaySleep 0
SoftwareUpdateTask 0
PreventSystemSleep 0
ExternalMedia 0
PreventUserIdleSystemSleep 1
NetworkClientActive 0
Listed by owning process:
pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "com.apple.iohideventsystem.queue.tickle serviceID:100000944 service:AppleMultitouchDevice product:Apple Internal Keyboard / Trackpad eventType:11"
\tTimeout will fire in 119 secs Action=TimeoutActionRelease
pid 664(Google Chrome): [0x00003c6000019337] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"
pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool"
\tDetails: caffeinate asserting for 300 secs
\tLocalized=THE CAFFEINATE TOOL IS PREVENTING SLEEP.
\tTimeout will fire in 287 secs Action=TimeoutActionRelease
pid 107(powerd): [0x00003a1100019304] 00:06:26 PreventUserIdleSystemSleep named: "Powerd - Prevent sleep while display is on"
No kernel assertions.
`;
describe("parseAssertions", () => {
it("matches a browser's NoIdleSleepAssertion (WebRTC) and ignores System-sleep noise", () => {
const users = parseAssertions(PMSET_WEBRTC_CALL);
// Chrome (NoIdleSleepAssertion) is in; caffeinate + powerd
// (PreventUserIdleSystemSleep) are filtered out.
expect(users).toEqual([{ executable: "Google Chrome", pid: 664 }]);
});
it("matches a native app's PreventUserIdleDisplaySleep assertion", () => {
const stdout = [
"Listed by owning process:",
` pid 4711(zoom.us): [0x00000ff100099303] 00:23:14 PreventUserIdleDisplaySleep named: "zoom.us is in a meeting"`,
].join("\n");
expect(parseAssertions(stdout)).toEqual([{ executable: "zoom.us", pid: 4711 }]);
});
it("does NOT match PreventUserIdleSystemSleep (caffeinate/powerd noise)", () => {
const stdout =
` pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool"`;
expect(parseAssertions(stdout)).toEqual([]);
});
it("dedupes a pid that holds multiple matching assertions (first wins)", () => {
const stdout = [
` pid 664(Google Chrome): [0xaaa] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"`,
` pid 664(Google Chrome): [0xbbb] 00:01:00 PreventUserIdleDisplaySleep named: "screen share"`,
].join("\n");
expect(parseAssertions(stdout)).toEqual([{ executable: "Google Chrome", pid: 664 }]);
});
it("returns an empty list when nothing holds a meeting assertion", () => {
const stdout = [
"Assertion status system-wide:",
" PreventUserIdleDisplaySleep 0",
"Listed by owning process:",
` pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "tickle"`,
].join("\n");
expect(parseAssertions(stdout)).toEqual([]);
});
});

View file

@ -17,7 +17,18 @@ const execFileAsync = promisify(execFile);
//
// Output format from `pmset -g assertions`:
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
// 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)/;
export class MacOsMicProbe implements MicProbe {
async probe(): Promise<MicUser[]> {
@ -32,16 +43,25 @@ export class MacOsMicProbe implements MicProbe {
return [];
}
const seen = new Map<number, MicUser>();
for (const line of stdout.split("\n")) {
const m = ASSERTION_LINE.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const command = m[2].trim();
if (!Number.isFinite(pid)) continue;
if (seen.has(pid)) continue;
seen.set(pid, { executable: command, pid });
}
return Array.from(seen.values());
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<number, MicUser>();
for (const line of stdout.split("\n")) {
const m = ASSERTION_LINE.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const command = m[2].trim();
if (!Number.isFinite(pid)) continue;
if (seen.has(pid)) continue;
seen.set(pid, { executable: command, pid });
}
return Array.from(seen.values());
}

View file

@ -134,7 +134,7 @@ export class MeetingDetectService {
} else {
this.notifier.notify(payload.notify);
}
await this.suppression.markNotified(event.sessionKey);
await this.suppression.markNotified(event.sessionKey, event.executable);
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
} catch (err) {
console.error("[MeetingDetect] popup failed:", err);

View file

@ -16,13 +16,37 @@ describe("Suppression", () => {
});
it("blocks re-popup for the same session once marked notified", async () => {
await suppression.markNotified("zoom.us#100");
await suppression.markNotified("zoom.us#100", "zoom.us");
expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
});
it("allows a different session for the same exe", async () => {
await suppression.markNotified("zoom.us#100");
expect(suppression.shouldNotify("zoom.us#101", "zoom.us")).toBe(true);
it("blocks a different session for the same exe within the notify cooldown", async () => {
// A flaky mic assertion clears the session and re-detects under a new key;
// the per-app cooldown must suppress the duplicate popup (issue #562 follow-up).
const t0 = new Date();
await suppression.markNotified("zoom.us#100", "zoom.us", t0);
const soon = new Date(t0.getTime() + 30 * 1000); // 30s later — within the 90s cooldown
expect(suppression.shouldNotify("zoom.us#101", "zoom.us", soon)).toBe(false);
});
it("allows the same exe again once the notify cooldown has elapsed", async () => {
const t0 = new Date();
await suppression.markNotified("zoom.us#100", "zoom.us", t0);
const after = new Date(t0.getTime() + 100 * 1000); // 100s — past the 90s cooldown
// Cooldown GC drops stale entries on reload, mirroring the dismiss-cooldown test.
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("zoom.us#101", "zoom.us", after)).toBe(true);
});
it("keeps the cooldown across clearSession (the flicker case)", async () => {
const t0 = new Date();
await suppression.markNotified("Google Chrome#664", "Google Chrome", t0);
// Mic assertion blinks out → detector clears the session.
await suppression.clearSession("Google Chrome#664");
// Same session re-detected moments later must NOT re-popup.
const soon = new Date(t0.getTime() + 30 * 1000);
expect(suppression.shouldNotify("Google Chrome#664", "Google Chrome", soon)).toBe(false);
});
it("respects the dismiss cooldown window", async () => {
@ -51,7 +75,7 @@ describe("Suppression", () => {
});
it("persists state through save/load", async () => {
await suppression.markNotified("zoom.us#100");
await suppression.markNotified("zoom.us#100", "zoom.us");
await suppression.muteApp("Discord");
const snap = store.snapshot();

View file

@ -5,12 +5,21 @@ import { WorkDir } from "@x/core/dist/config/config.js";
const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json");
// Don't re-popup for the same exe within this window if the user dismissed.
const DISMISS_COOLDOWN_MS = 30 * 60 * 1000;
// After showing a popup for an app, stay quiet for the SAME app for this long —
// even if the mic/WebRTC assertion flickers and the detector clears + re-fires
// the session. The macOS pmset assertion blinks out for stretches of a single
// call (observed dropouts maxed around 84s), which would otherwise re-fire a
// fresh popup every time it reappears. 90s covers the worst observed flicker.
const NOTIFY_COOLDOWN_MS = 90 * 1000;
// Drop session-key entries older than 24h.
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
interface SuppressionState {
// Mic sessions we've already shown a popup for — keyed by detector sessionKey.
notifiedSessions: Record<string, { notifiedAt: string }>;
// Apps we've recently shown a popup for — keyed by exe basename. Drives the
// per-app cooldown; survives clearSession() so a flickering session can't spam.
recentlyNotified: Record<string, { notifiedAt: string }>;
// User explicitly dismissed for this exe at this time.
recentlyDismissed: Record<string, { dismissedAt: string }>;
// Permanent "never offer for this app" list — exe substring matches.
@ -18,7 +27,7 @@ interface SuppressionState {
}
function empty(): SuppressionState {
return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] };
return { notifiedSessions: {}, recentlyNotified: {}, recentlyDismissed: {}, mutedApps: [] };
}
export interface SuppressionStore {
@ -52,6 +61,7 @@ function normalize(raw: unknown): SuppressionState {
const obj = raw as Partial<SuppressionState>;
return {
notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {},
recentlyNotified: obj.recentlyNotified && typeof obj.recentlyNotified === "object" ? obj.recentlyNotified : {},
recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {},
mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [],
};
@ -77,8 +87,19 @@ export class Suppression {
if (this.isMuted(executable)) return false;
if (this.state.notifiedSessions[sessionKey]) return false;
const dismissKey = dismissKeyFor(executable);
const recent = this.state.recentlyDismissed[dismissKey];
const appKey = dismissKeyFor(executable);
// Per-app cooldown: once we've popped for this app, stay quiet for the
// window even if the session cleared and re-fired (flaky mic assertion).
const lastNotified = this.state.recentlyNotified[appKey];
if (lastNotified) {
const notifiedAt = Date.parse(lastNotified.notifiedAt);
if (Number.isFinite(notifiedAt) && now.getTime() - notifiedAt < NOTIFY_COOLDOWN_MS) {
return false;
}
}
const recent = this.state.recentlyDismissed[appKey];
if (recent) {
const dismissedAt = Date.parse(recent.dismissedAt);
if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) {
@ -88,8 +109,11 @@ export class Suppression {
return true;
}
async markNotified(sessionKey: string, now: Date = new Date()): Promise<void> {
async markNotified(sessionKey: string, executable: string, now: Date = new Date()): Promise<void> {
this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() };
// App-level stamp drives the cooldown and is intentionally NOT removed by
// clearSession() — that's what makes a flickering session stop re-popping.
this.state.recentlyNotified[dismissKeyFor(executable)] = { notifiedAt: now.toISOString() };
await this.persist();
}
@ -146,12 +170,17 @@ function gc(state: SuppressionState): SuppressionState {
const ts = Date.parse(v.notifiedAt);
if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v;
}
const notified: SuppressionState["recentlyNotified"] = {};
for (const [k, v] of Object.entries(state.recentlyNotified)) {
const ts = Date.parse(v.notifiedAt);
if (Number.isFinite(ts) && now - ts < NOTIFY_COOLDOWN_MS) notified[k] = v;
}
const dismissed: SuppressionState["recentlyDismissed"] = {};
for (const [k, v] of Object.entries(state.recentlyDismissed)) {
const ts = Date.parse(v.dismissedAt);
if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v;
}
return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
return { notifiedSessions: sessions, recentlyNotified: notified, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
}
/** In-memory store for tests. */