Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions apps/x/apps/main/src/meeting-detect/browser-match.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});

Expand All @@ -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();
Expand All @@ -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();
});
});
63 changes: 47 additions & 16 deletions apps/x/apps/main/src/meeting-detect/browser-match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
76 changes: 76 additions & 0 deletions apps/x/apps/main/src/meeting-detect/probe-macos.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
63 changes: 31 additions & 32 deletions apps/x/apps/main/src/meeting-detect/probe-macos.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,46 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { execFileSync } from "node:child_process";
import type { MicProbe, MicUser } from "./types.js";

const execFileAsync = promisify(execFile);
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdleDisplaySleep|NoIdleSleepAssertion)/;

// 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: "..."
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
const PMSET_TIMEOUT_MS = 4_000;

// 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,
});
}

export class MacOsMicProbe implements MicProbe {
async probe(): Promise<MicUser[]> {
let stdout: string;
try {
const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], {
timeout: 10_000,
});
stdout = result.stdout;
stdout = runPmsetAssertions();
} catch (err) {
console.error("[MeetingDetect] macOS probe failed:", err);
return [];
}
return parseAssertions(stdout);
}
}

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());
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());
}
2 changes: 1 addition & 1 deletion apps/x/apps/main/src/meeting-detect/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
34 changes: 29 additions & 5 deletions apps/x/apps/main/src/meeting-detect/suppression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
Loading