feat: detect mic-in-use meetings and prompt for note-taking

This commit is contained in:
Gagancreates 2026-05-15 14:06:51 +05:30 committed by Arjun
parent d981fa9206
commit 6c9d9206c8
22 changed files with 2031 additions and 17 deletions

View file

@ -10,7 +10,8 @@
"start": "electron .",
"build": "rm -rf dist && tsc && node bundle.mjs",
"package": "electron-forge package",
"make": "electron-forge make"
"make": "electron-forge make",
"test": "vitest run"
},
"dependencies": {
"@x/core": "workspace:*",
@ -37,6 +38,7 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.0.3",
"electron": "^39.2.7",
"esbuild": "^0.24.2"
"esbuild": "^0.24.2",
"vitest": "^2.1.9"
}
}

View file

@ -63,7 +63,11 @@ export function dispatchDeepLink(url: string): void {
interface MeetingNotesAction {
type: "take-meeting-notes" | "join-and-take-meeting-notes";
eventId: string;
// eventId is required for join-and-take-meeting-notes (calendar-time fire)
// but optional for take-meeting-notes — mic-detection ad-hoc fires use a
// title-only payload when the call isn't on the calendar.
eventId?: string;
title?: string;
}
type ParsedAction = MeetingNotesAction;
@ -76,10 +80,16 @@ function parseAction(url: string): ParsedAction | null {
if (host !== ACTION_HOST) return null;
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
const type = params.get("type");
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
const eventId = params.get("eventId");
const eventId = params.get("eventId") || undefined;
const title = params.get("title") || undefined;
if (type === "join-and-take-meeting-notes") {
return eventId ? { type, eventId } : null;
}
if (type === "take-meeting-notes") {
// Need at least one identifier — eventId (calendar) or title (ad-hoc).
if (!eventId && !title) return null;
return { type, eventId, title };
}
return null;
}
@ -88,25 +98,31 @@ async function dispatchAction(url: string): Promise<void> {
if (!parsed) return;
const openMeeting = parsed.type === "join-and-take-meeting-notes";
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
await handleTakeMeetingNotes(parsed.eventId, parsed.title, openMeeting);
}
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
async function handleTakeMeetingNotes(
eventId: string | undefined,
title: string | undefined,
openMeeting: boolean,
): Promise<void> {
const win = mainWindowRef;
if (!win || win.isDestroyed()) return;
focusWindow(win);
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
let event: unknown;
try {
const raw = await fs.readFile(filePath, "utf-8");
event = JSON.parse(raw);
} catch (err) {
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
return;
let event: unknown = null;
if (eventId) {
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
try {
const raw = await fs.readFile(filePath, "utf-8");
event = JSON.parse(raw);
} catch (err) {
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
// Fall through with event=null so the renderer can still open an ad-hoc note.
}
}
const payload = { event, openMeeting };
const payload = { event, openMeeting, title: title ?? null };
if (win.webContents.isLoading()) {
win.webContents.once("did-finish-load", () => {

View file

@ -45,6 +45,11 @@ import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
import {
createPlatformDetector,
MeetingDetectService,
Suppression,
} from "./meeting-detect/index.js";
import {
DEEP_LINK_SCHEME,
dispatchUrl,
@ -312,7 +317,8 @@ app.whenReady().then(async () => {
});
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
const notificationService = new ElectronNotificationService();
registerNotificationService(notificationService);
setupIpcHandlers();
setupBrowserEventForwarding();
@ -384,6 +390,21 @@ app.whenReady().then(async () => {
// start calendar meeting notification service (fires 1-minute warnings)
initCalendarNotifications();
// start meeting-detect service (mic-in-use detection -> popup asking if user wants notes)
const meetingDetector = createPlatformDetector();
if (meetingDetector) {
const meetingDetectService = new MeetingDetectService({
detector: meetingDetector,
notifier: notificationService,
suppression: new Suppression(),
});
meetingDetectService.start().catch((err) => {
console.error("[MeetingDetect] failed to start:", err);
});
} else {
console.log("[MeetingDetect] no detector for this platform; skipping");
}
// start chrome extension sync server
initChromeSync();

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { matchTitleOrUrl } from "./browser-match.js";
describe("matchTitleOrUrl", () => {
it("matches Google Meet by URL", () => {
const m = matchTitleOrUrl("Meet — Standup", "https://meet.google.com/abc-defg-hij");
expect(m?.platform).toBe("google-meet");
});
it("matches Google Meet by window title alone (Windows/Mac no-URL case)", () => {
const m = matchTitleOrUrl("Meet - Daily Standup - Google Chrome", undefined);
expect(m?.platform).toBe("google-meet");
});
it("matches Meet with em-dash variant (locale-dependent title)", () => {
const m = matchTitleOrUrl("Meet — Daily Standup", undefined);
expect(m?.platform).toBe("google-meet");
});
it("matches Zoom web client", () => {
const m = matchTitleOrUrl("Zoom Meeting", "https://us02web.zoom.us/j/123456789");
expect(m?.platform).toBe("zoom-web");
});
it("matches Teams web", () => {
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
expect(m?.platform).toBe("teams-web");
});
it("ignores random YouTube tab", () => {
const m = matchTitleOrUrl("Mock Interview - YouTube", "https://www.youtube.com/watch?v=abc");
expect(m).toBeNull();
});
it("returns null for empty input", () => {
expect(matchTitleOrUrl(undefined, undefined)).toBeNull();
expect(matchTitleOrUrl("", "")).toBeNull();
});
it("is case-insensitive", () => {
const m = matchTitleOrUrl("ZOOM MEETING", "https://ZOOM.US/J/999");
expect(m?.platform).toBe("zoom-web");
});
});

View file

@ -0,0 +1,63 @@
import { getForegroundWindow } from "./foreground-window.js";
export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web";
export interface BrowserMeetingMatch {
platform: BrowserMeetingPlatform;
// Best-effort URL or tab title we matched on — useful for the popup copy.
hint: string;
}
interface TitleRule {
platform: BrowserMeetingPlatform;
// Substrings checked against the (case-insensitive) window title / URL.
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"
const RULES: TitleRule[] = [
{ 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: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] },
];
/**
* 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.
*
* Caller is expected to only invoke this when the detector classified the
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap we
* only ask the OS when there's a reason to ask.
*/
export async function matchBrowserMeeting(): Promise<BrowserMeetingMatch | null> {
const win = await getForegroundWindow();
if (!win) return null;
// We only have a title (no URL from these OS calls), but Chrome / Edge /
// Firefox include the tab title in the window title, which contains the
// meeting service name for Meet/Zoom-web/Teams-web pages.
return matchTitleOrUrl(win.title, undefined);
}
/** Pure matcher — exposed for tests; no OS calls. */
export function matchTitleOrUrl(title: string | undefined, url: string | undefined): BrowserMeetingMatch | null {
// active-win returns `url` on macOS for Chromium-family + Safari (Accessibility-perm gated).
// On Windows, only `title` is reliable. Match against both.
const haystack = `${url ?? ""}\n${title ?? ""}`.toLowerCase();
if (!haystack.trim()) return null;
for (const rule of RULES) {
for (const needle of rule.needles) {
if (haystack.includes(needle)) {
return { platform: rule.platform, hint: url || title || "" };
}
}
}
return null;
}

View file

@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import { correlateFromDir } from "./calendar-correlate.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rb-meeting-detect-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function writeEvent(name: string, body: unknown): Promise<void> {
await fs.writeFile(path.join(tmpDir, `${name}.json`), JSON.stringify(body), "utf-8");
}
function evt(opts: {
id: string;
summary: string;
startMinutes: number; // minutes from `anchor`
endMinutes: number;
cancelled?: boolean;
declined?: boolean;
hangoutLink?: string;
}): unknown {
const anchor = new Date("2026-05-15T10:00:00Z").getTime();
return {
id: opts.id,
summary: opts.summary,
status: opts.cancelled ? "cancelled" : "confirmed",
start: { dateTime: new Date(anchor + opts.startMinutes * 60_000).toISOString() },
end: { dateTime: new Date(anchor + opts.endMinutes * 60_000).toISOString() },
attendees: [
{ self: true, responseStatus: opts.declined ? "declined" : "accepted" },
{ email: "alice@example.com", displayName: "Alice" },
],
hangoutLink: opts.hangoutLink,
};
}
describe("correlateFromDir", () => {
const NOW = new Date("2026-05-15T10:30:00Z");
it("returns null when the directory does not exist", async () => {
const result = await correlateFromDir(path.join(tmpDir, "does-not-exist"), NOW);
expect(result).toBeNull();
});
it("returns null when no events overlap", async () => {
await writeEvent("e1", evt({ id: "e1", summary: "Morning", startMinutes: -120, endMinutes: -60 }));
const result = await correlateFromDir(tmpDir, NOW);
expect(result).toBeNull();
});
it("matches an event in progress", async () => {
await writeEvent("e1", evt({
id: "e1",
summary: "Q2 Planning",
startMinutes: 25, // 10:25, NOW=10:30 → in progress
endMinutes: 55,
hangoutLink: "https://meet.google.com/abc",
}));
const result = await correlateFromDir(tmpDir, NOW);
expect(result?.eventId).toBe("e1");
expect(result?.summary).toBe("Q2 Planning");
expect(result?.meetingUrl).toBe("https://meet.google.com/abc");
expect(result?.attendees).toHaveLength(1); // self filtered
expect(result?.attendees[0].email).toBe("alice@example.com");
});
it("matches an event starting within pre-roll", async () => {
await writeEvent("e1", evt({
id: "e1",
summary: "Upcoming",
startMinutes: 31, // NOW=10:30, event at 10:31 → 1 min away, within 2-min pre-roll
endMinutes: 60,
}));
const result = await correlateFromDir(tmpDir, NOW);
expect(result?.eventId).toBe("e1");
});
it("ignores cancelled events", async () => {
await writeEvent("e1", evt({ id: "e1", summary: "Dead", startMinutes: 25, endMinutes: 55, cancelled: true }));
const result = await correlateFromDir(tmpDir, NOW);
expect(result).toBeNull();
});
it("ignores events the user declined", async () => {
await writeEvent("e1", evt({ id: "e1", summary: "Nope", startMinutes: 25, endMinutes: 55, declined: true }));
const result = await correlateFromDir(tmpDir, NOW);
expect(result).toBeNull();
});
it("picks the closest event when multiple overlap", async () => {
await writeEvent("far", evt({ id: "far", summary: "Far", startMinutes: -10, endMinutes: 35 }));
await writeEvent("near", evt({ id: "near", summary: "Near", startMinutes: 29, endMinutes: 59 }));
const result = await correlateFromDir(tmpDir, NOW);
expect(result?.eventId).toBe("near");
});
it("ignores sync_state.json", async () => {
await writeEvent("sync_state", { lastSync: "whatever" });
await writeEvent("e1", evt({ id: "e1", summary: "Real", startMinutes: 25, endMinutes: 55 }));
const result = await correlateFromDir(tmpDir, NOW);
expect(result?.eventId).toBe("e1");
});
});

View file

@ -0,0 +1,123 @@
import path from "node:path";
import fs from "node:fs/promises";
import { WorkDir } from "@x/core/dist/config/config.js";
// Match a detection event against the user's synced calendar. The detector
// fires when the mic flips on; if there's a calendar event currently in
// progress (or about to start / just ended), we attach its metadata so the
// popup can show the right title and the deeplink can target the right note.
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
// Pre-roll: someone joining 2 min early should still match the upcoming event.
const PRE_ROLL_MS = 2 * 60 * 1000;
// Post-roll: someone joining 2 min late (or a meeting that ran long and the
// next-event window already started) should still match.
const POST_ROLL_MS = 2 * 60 * 1000;
interface CalendarEventFile {
id?: string;
summary?: string;
status?: string;
start?: { dateTime?: string };
end?: { dateTime?: string };
attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>;
hangoutLink?: string;
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> };
}
export interface CorrelatedEvent {
eventId: string;
summary: string;
startMs: number;
endMs: number;
attendees: Array<{ email?: string; displayName?: string }>;
meetingUrl?: string;
}
/**
* Find a calendar event whose [start - PRE_ROLL, end + POST_ROLL] window
* contains `now`. Returns the closest match (smallest |now - start|) when
* multiple events overlap (back-to-back meetings).
*/
export async function correlateNow(now: Date = new Date()): Promise<CorrelatedEvent | null> {
return correlateFromDir(CALENDAR_SYNC_DIR, now);
}
/** Exposed for tests — accepts an arbitrary directory of calendar JSON files. */
export async function correlateFromDir(dir: string, now: Date): Promise<CorrelatedEvent | null> {
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
return null;
}
const nowMs = now.getTime();
let best: { event: CorrelatedEvent; distance: number } | null = null;
for (const name of entries) {
if (!name.endsWith(".json")) continue;
if (name === "sync_state.json" || name.startsWith("sync_state")) continue;
let raw: string;
try {
raw = await fs.readFile(path.join(dir, name), "utf-8");
} catch {
continue;
}
let event: CalendarEventFile;
try {
event = JSON.parse(raw);
} catch {
continue;
}
if (event.status === "cancelled") continue;
if (isDeclinedBySelf(event)) continue;
const startStr = event.start?.dateTime;
const endStr = event.end?.dateTime;
if (!startStr || !endStr) continue;
const startMs = Date.parse(startStr);
const endMs = Date.parse(endStr);
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
// Skip events outside the active window.
if (nowMs < startMs - PRE_ROLL_MS) continue;
if (nowMs > endMs + POST_ROLL_MS) continue;
const eventId = event.id || name.replace(/\.json$/, "");
const correlated: CorrelatedEvent = {
eventId,
summary: event.summary?.trim() || "Untitled meeting",
startMs,
endMs,
attendees: (event.attendees || [])
.filter((a) => !a.self)
.map((a) => ({ email: a.email, displayName: a.displayName })),
meetingUrl: extractMeetingUrl(event),
};
const distance = Math.abs(nowMs - startMs);
if (!best || distance < best.distance) {
best = { event: correlated, distance };
}
}
return best?.event ?? null;
}
function isDeclinedBySelf(event: CalendarEventFile): boolean {
if (!event.attendees) return false;
const self = event.attendees.find((a) => a.self);
return self?.responseStatus === "declined";
}
function extractMeetingUrl(event: CalendarEventFile): string | undefined {
if (event.hangoutLink) return event.hangoutLink;
const eps = event.conferenceData?.entryPoints || [];
const video = eps.find((e) => e.entryPointType === "video");
return video?.uri;
}

View file

@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MeetingDetector, type MeetingActiveEvent, type MeetingClearedEvent } from "./detector.js";
import type { MicProbe, MicUser } from "./types.js";
class FakeProbe implements MicProbe {
private next: MicUser[] = [];
setNext(users: MicUser[]): void { this.next = users; }
async probe(): Promise<MicUser[]> { return this.next; }
}
function collect(detector: MeetingDetector) {
const active: MeetingActiveEvent[] = [];
const cleared: MeetingClearedEvent[] = [];
detector.on("meeting-active", (e) => active.push(e));
detector.on("meeting-cleared", (e) => cleared.push(e));
return { active, cleared };
}
describe("MeetingDetector", () => {
let probe: FakeProbe;
let detector: MeetingDetector;
beforeEach(() => {
probe = new FakeProbe();
// tickMs is irrelevant — we drive ticks manually.
detector = new MeetingDetector(probe, 999_999);
});
it("emits meeting-active once when a Zoom-like exe appears", async () => {
const { active } = collect(detector);
probe.setNext([{ executable: "C:\\Program Files\\Zoom\\bin\\Zoom.exe" }]);
await detector.tick();
expect(active).toHaveLength(1);
expect(active[0].kind).toBe("zoom");
expect(active[0].executable).toContain("Zoom.exe");
});
it("does not re-emit while the same exe keeps appearing", async () => {
const { active } = collect(detector);
const user = { executable: "/Applications/zoom.us.app/Contents/MacOS/zoom.us", pid: 4711 };
probe.setNext([user]);
await detector.tick();
await detector.tick();
await detector.tick();
expect(active).toHaveLength(1);
});
it("emits meeting-cleared when the exe disappears", async () => {
const { active, cleared } = collect(detector);
const user = { executable: "zoom.us", pid: 4711 };
probe.setNext([user]);
await detector.tick();
probe.setNext([]);
await detector.tick();
expect(active).toHaveLength(1);
expect(cleared).toHaveLength(1);
expect(cleared[0].sessionKey).toBe(active[0].sessionKey);
});
it("ignores unknown executables (Voice Memos, OBS, etc.)", async () => {
const { active, cleared } = collect(detector);
probe.setNext([{ executable: "Voice Memos", pid: 999 }]);
await detector.tick();
probe.setNext([]);
await detector.tick();
expect(active).toHaveLength(0);
expect(cleared).toHaveLength(0);
});
it("classifies a browser as kind=browser (for downstream tab-title check)", async () => {
const { active } = collect(detector);
probe.setNext([{ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", pid: 5050 }]);
await detector.tick();
expect(active).toHaveLength(1);
expect(active[0].kind).toBe("browser");
});
it("treats a relaunched app (new pid) as a new session on macOS", async () => {
const { active, cleared } = collect(detector);
probe.setNext([{ executable: "zoom.us", pid: 100 }]);
await detector.tick();
probe.setNext([]); // app closed
await detector.tick();
probe.setNext([{ executable: "zoom.us", pid: 200 }]); // re-opened
await detector.tick();
expect(active).toHaveLength(2);
expect(cleared).toHaveLength(1);
expect(active[0].sessionKey).not.toBe(active[1].sessionKey);
});
it("handles multiple concurrent meeting apps independently", async () => {
const { active, cleared } = collect(detector);
probe.setNext([
{ executable: "zoom.us", pid: 100 },
{ executable: "Microsoft Teams", pid: 200 },
]);
await detector.tick();
probe.setNext([{ executable: "Microsoft Teams", pid: 200 }]);
await detector.tick();
expect(active).toHaveLength(2);
expect(active.map((e) => e.kind).sort()).toEqual(["teams", "zoom"]);
expect(cleared).toHaveLength(1);
expect(cleared[0].sessionKey).toContain("zoom.us");
});
it("recovers without crashing when the probe throws", async () => {
const flaky: MicProbe = { probe: vi.fn().mockRejectedValueOnce(new Error("boom")) };
const d = new MeetingDetector(flaky, 999_999);
// tick() awaits probe.probe() so a rejection bubbles — start() catches it. Verify start() doesn't throw.
d.start();
await new Promise((r) => setTimeout(r, 10));
d.stop();
expect(flaky.probe).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,107 @@
import { EventEmitter } from "node:events";
import { classifyExecutable, type MeetingAppKind } from "./meeting-apps.js";
import type { MicProbe, MicUser } from "./types.js";
const DEFAULT_TICK_MS = 3_000;
export interface MeetingActiveEvent {
executable: string;
pid?: number;
kind: MeetingAppKind;
// Stable key for dedup — exe path (plus pid on mac so a Zoom relaunch counts as a new session).
sessionKey: string;
startedAt: Date;
}
export interface MeetingClearedEvent {
sessionKey: string;
endedAt: Date;
}
/**
* Polls a platform-specific MicProbe and emits when a whitelisted meeting app
* starts / stops holding the mic. One emit per distinct session a session
* lasts as long as the same exe (+pid on macOS) keeps appearing in probe
* results across ticks.
*
* Pure logic; UI/notification wiring lives in the service layer. Probe is
* injected so this is testable without a real OS.
*/
export class MeetingDetector extends EventEmitter {
private readonly probe: MicProbe;
private readonly tickMs: number;
private active = new Map<string, MeetingActiveEvent>();
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(probe: MicProbe, tickMs: number = DEFAULT_TICK_MS) {
super();
this.probe = probe;
this.tickMs = tickMs;
}
start(): void {
if (this.timer) return;
const loop = async () => {
if (!this.running) return;
try {
await this.tick();
} catch (err) {
console.error("[MeetingDetect] tick failed:", err);
}
if (this.running) this.timer = setTimeout(loop, this.tickMs);
};
this.running = true;
// Run first tick immediately; subsequent ticks scheduled by the loop.
this.timer = setTimeout(loop, 0);
}
stop(): void {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/** Exposed for tests — drive a single probe-and-diff cycle. */
async tick(): Promise<void> {
const users = await this.probe.probe();
const seenKeys = new Set<string>();
const now = new Date();
for (const user of users) {
const kind = classifyExecutable(user.executable);
if (kind === "unknown") continue;
const key = sessionKey(user);
seenKeys.add(key);
if (!this.active.has(key)) {
const event: MeetingActiveEvent = {
executable: user.executable,
pid: user.pid,
kind,
sessionKey: key,
startedAt: now,
};
this.active.set(key, event);
this.emit("meeting-active", event);
}
}
for (const [key, event] of this.active) {
if (seenKeys.has(key)) continue;
this.active.delete(key);
const cleared: MeetingClearedEvent = { sessionKey: key, endedAt: now };
this.emit("meeting-cleared", cleared);
}
}
}
function sessionKey(user: MicUser): string {
// On macOS we include pid so an app relaunch counts as a new session.
// On Windows there's no pid; the exe path alone is sufficient because
// Windows can't tell us *which instance* of an exe is holding the mic.
return user.pid !== undefined ? `${user.executable}#${user.pid}` : user.executable;
}

View file

@ -0,0 +1,97 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface ForegroundWindow {
title: string;
// Best-effort process name; we don't always get this from osascript.
appName?: string;
}
/**
* Read the title of whatever window is in the foreground. Cross-platform,
* zero native deps shells out to a built-in OS tool. Returns null if the
* platform isn't supported or the call fails.
*
* We dropped `active-win` because its prebuilt native binary depends on
* runtime package.json lookups that don't survive esbuild bundling.
*/
export async function getForegroundWindow(): Promise<ForegroundWindow | null> {
if (process.platform === "win32") return getForegroundWindowWindows();
if (process.platform === "darwin") return getForegroundWindowMacOS();
return null;
}
// Win32 GetForegroundWindow + GetWindowText via inline P/Invoke in PowerShell.
// Single one-shot call; cheap enough to run on every meeting-active event.
const WINDOWS_SCRIPT = `
$src = @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public class RowboatFW {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll", SetLastError=true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
}
'@
Add-Type -TypeDefinition $src -ErrorAction SilentlyContinue
$hwnd = [RowboatFW]::GetForegroundWindow()
$sb = New-Object System.Text.StringBuilder 1024
[RowboatFW]::GetWindowText($hwnd, $sb, $sb.Capacity) | Out-Null
$pid2 = 0
[RowboatFW]::GetWindowThreadProcessId($hwnd, [ref]$pid2) | Out-Null
$proc = $null
try { $proc = (Get-Process -Id $pid2 -ErrorAction SilentlyContinue).ProcessName } catch {}
[PSCustomObject]@{ Title = $sb.ToString(); App = $proc } | ConvertTo-Json -Compress
`.trim();
async function getForegroundWindowWindows(): Promise<ForegroundWindow | null> {
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT],
{ timeout: 5_000, windowsHide: true },
);
const trimmed = stdout.trim();
if (!trimmed) return null;
const parsed = JSON.parse(trimmed) as { Title?: string; App?: string };
if (typeof parsed.Title !== "string") return null;
return { title: parsed.Title, appName: parsed.App };
} catch (err) {
console.error("[MeetingDetect] foreground-window (windows) failed:", err);
return null;
}
}
// macOS via osascript — title of the frontmost window of the frontmost app.
// Requires Accessibility permission for the Electron app; without it, the
// `name of front window` lookup returns empty.
const MACOS_SCRIPT = `
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set winTitle to name of front window of frontApp
on error
set winTitle to ""
end try
return appName & "\\n" & winTitle
end tell
`.trim();
async function getForegroundWindowMacOS(): Promise<ForegroundWindow | null> {
try {
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], {
timeout: 5_000,
});
const [appName, ...titleParts] = stdout.trim().split("\n");
return { title: titleParts.join("\n"), appName };
} catch (err) {
console.error("[MeetingDetect] foreground-window (macOS) failed:", err);
return null;
}
}

View file

@ -0,0 +1,26 @@
import { MeetingDetector } from "./detector.js";
import { WindowsMicProbe } from "./probe-windows.js";
import { MacOsMicProbe } from "./probe-macos.js";
import type { MicProbe } from "./types.js";
export { MeetingDetector } from "./detector.js";
export type { MeetingActiveEvent, MeetingClearedEvent } from "./detector.js";
export { classifyExecutable, isMeetingApp, isBrowser } from "./meeting-apps.js";
export type { MeetingAppKind } from "./meeting-apps.js";
export type { MicProbe, MicUser } from "./types.js";
export { Suppression, InMemorySuppressionStore } from "./suppression.js";
export type { SuppressionStore } from "./suppression.js";
export { MeetingDetectService, buildPopup } from "./service.js";
export type { MeetingDetectServiceOptions } from "./service.js";
export function createPlatformDetector(): MeetingDetector | null {
const probe = createPlatformProbe();
if (!probe) return null;
return new MeetingDetector(probe);
}
function createPlatformProbe(): MicProbe | null {
if (process.platform === "win32") return new WindowsMicProbe();
if (process.platform === "darwin") return new MacOsMicProbe();
return null;
}

View file

@ -0,0 +1,49 @@
// Whitelist of executables / bundle IDs we treat as "the user is in a meeting"
// when they're holding the microphone. Native meeting apps map 1:1; browsers
// map to "maybe — check the foreground tab title before firing."
export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown";
interface AppRule {
kind: MeetingAppKind;
// Case-insensitive substring match against the executable path / basename
// (Windows: full exe path from registry; macOS: command name from lsof).
match: string[];
}
const RULES: AppRule[] = [
{ kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] },
{ kind: "teams", match: ["ms-teams.exe", "teams.exe", "microsoft teams"] },
{ kind: "slack", match: ["slack.exe", "slack helper", "slack"] },
{ kind: "discord", match: ["discord.exe", "discord"] },
{ kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },
// Browsers — kind "browser" means we still need a tab-title check before firing.
{ kind: "browser", match: [
"chrome.exe", "google chrome",
"msedge.exe", "microsoft edge",
"firefox.exe", "firefox",
"arc.exe", "arc",
"brave.exe", "brave browser",
"safari",
"vivaldi.exe", "vivaldi",
"opera.exe", "opera",
]},
];
export function classifyExecutable(executable: string): MeetingAppKind {
const haystack = executable.toLowerCase();
for (const rule of RULES) {
for (const needle of rule.match) {
if (haystack.includes(needle)) return rule.kind;
}
}
return "unknown";
}
export function isMeetingApp(executable: string): boolean {
return classifyExecutable(executable) !== "unknown";
}
export function isBrowser(executable: string): boolean {
return classifyExecutable(executable) === "browser";
}

View file

@ -0,0 +1,47 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { MicProbe, MicUser } from "./types.js";
const execFileAsync = promisify(execFile);
// 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+)/;
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;
} catch (err) {
console.error("[MeetingDetect] macOS probe failed:", err);
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());
}
}

View file

@ -0,0 +1,85 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { MicProbe, MicUser } from "./types.js";
const execFileAsync = promisify(execFile);
// Windows records every mic-using app under CapabilityAccessManager. Each app
// subkey has LastUsedTimeStart and LastUsedTimeStop (FILETIME, int64). When
// Start > Stop, the app is currently holding the mic. Subkey names under
// NonPackaged are the executable path with `\` replaced by `#`.
//
// We shell out to PowerShell (single Get-ChildItem walk) rather than pulling
// in a native registry binding — far simpler to ship inside Electron and the
// poll cadence is 3s, so spawn cost is irrelevant.
const POWERSHELL_SCRIPT = `
$paths = @(
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged',
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'
)
$out = New-Object System.Collections.ArrayList
foreach ($p in $paths) {
if (-not (Test-Path $p)) { continue }
Get-ChildItem -Path $p -ErrorAction SilentlyContinue | ForEach-Object {
$props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue
if ($null -eq $props) { return }
$start = $props.LastUsedTimeStart
$stop = $props.LastUsedTimeStop
if ($null -ne $start -and $null -ne $stop -and $start -gt $stop) {
[void]$out.Add([PSCustomObject]@{ Name = $_.PSChildName })
}
}
}
$out | ConvertTo-Json -Compress
`.trim();
interface RawRow {
Name?: string;
}
function decodeNonPackagedName(name: string): string {
// NonPackaged subkeys: "C:#Program Files#Zoom#bin#Zoom.exe" → "C:\Program Files\Zoom\bin\Zoom.exe"
// Packaged subkeys are AUMIDs (e.g. "Microsoft.Teams_..._mscorlib") — leave as-is.
if (name.includes("#") && !name.includes("\\")) {
return name.replace(/#/g, "\\");
}
return name;
}
export class WindowsMicProbe implements MicProbe {
async probe(): Promise<MicUser[]> {
let stdout: string;
try {
const result = await execFileAsync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT],
{ timeout: 10_000, windowsHide: true },
);
stdout = result.stdout.trim();
} catch (err) {
console.error("[MeetingDetect] Windows probe failed:", err);
return [];
}
if (!stdout) return [];
let parsed: RawRow[] | RawRow;
try {
parsed = JSON.parse(stdout);
} catch (err) {
console.error("[MeetingDetect] Windows probe parse failed:", err);
return [];
}
// ConvertTo-Json emits a single object (not an array) when the list has one item.
const rows: RawRow[] = Array.isArray(parsed) ? parsed : [parsed];
const seen = new Set<string>();
const out: MicUser[] = [];
for (const row of rows) {
if (!row || typeof row.Name !== "string") continue;
const exe = decodeNonPackagedName(row.Name);
if (seen.has(exe)) continue;
seen.add(exe);
out.push({ executable: exe });
}
return out;
}
}

View file

@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
import { MeetingDetector } from "./detector.js";
import type { MicProbe, MicUser } from "./types.js";
import { MeetingDetectService, buildPopup } from "./service.js";
import { Suppression, InMemorySuppressionStore } from "./suppression.js";
import type { BrowserMeetingMatch } from "./browser-match.js";
import type { CorrelatedEvent } from "./calendar-correlate.js";
class FakeProbe implements MicProbe {
next: MicUser[] = [];
async probe(): Promise<MicUser[]> { return this.next; }
}
class FakeNotifier implements INotificationService {
sent: NotifyInput[] = [];
isSupported(): boolean { return true; }
notify(input: NotifyInput): void { this.sent.push(input); }
}
describe("buildPopup", () => {
it("uses the calendar event summary when correlated", () => {
const corr: CorrelatedEvent = {
eventId: "abc123",
summary: "Q2 Planning",
startMs: 0, endMs: 0,
attendees: [],
};
const popup = buildPopup("zoom", null, corr);
expect(popup?.notify.message).toContain("Q2 Planning");
expect(popup?.notify.link).toContain("eventId=abc123");
expect(popup?.notify.link).toContain("take-meeting-notes");
});
it("falls back to ad-hoc copy when no calendar match", () => {
const popup = buildPopup("zoom", null, null);
expect(popup?.notify.title).toBe("You're in a meeting");
expect(popup?.notify.link).toContain("title=");
expect(popup?.notify.link).not.toContain("eventId=");
});
it("uses browser match platform label when kind=browser", () => {
const m: BrowserMeetingMatch = { platform: "google-meet", hint: "https://meet.google.com/abc" };
const popup = buildPopup("browser", m, null);
expect(popup?.notify.message).toContain("Google Meet");
});
it("returns null for unknown app without browser match (defensive)", () => {
expect(buildPopup("unknown", null, null)).toBeNull();
});
});
describe("MeetingDetectService end-to-end", () => {
let probe: FakeProbe;
let detector: MeetingDetector;
let notifier: FakeNotifier;
let suppression: Suppression;
beforeEach(() => {
probe = new FakeProbe();
detector = new MeetingDetector(probe, 999_999);
notifier = new FakeNotifier();
suppression = new Suppression(new InMemorySuppressionStore());
});
it("fires notification when a zoom call is detected, with calendar context", async () => {
const correlated: CorrelatedEvent = {
eventId: "evt-1",
summary: "Standup",
startMs: 0, endMs: 0,
attendees: [],
};
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => null,
correlate: async () => correlated,
});
await service.start();
probe.next = [{ executable: "zoom.us", pid: 100 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(1);
expect(notifier.sent[0].title).toBe("Take notes for this meeting?");
expect(notifier.sent[0].message).toContain("Standup");
expect(notifier.sent[0].link).toContain("eventId=evt-1");
});
it("does NOT fire for a browser if the foreground tab is not a meeting page", async () => {
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => null, // browser foreground = not a meeting
correlate: async () => null,
});
await service.start();
probe.next = [{ executable: "Google Chrome", pid: 200 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(0);
});
it("FIRES for a browser when the foreground tab IS a meeting page", async () => {
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }),
correlate: async () => null,
});
await service.start();
probe.next = [{ executable: "Google Chrome", pid: 200 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(1);
expect(notifier.sent[0].message).toContain("Google Meet");
expect(notifier.sent[0].link).toContain("title="); // ad-hoc, no eventId
});
it("does not re-fire on consecutive ticks for the same session", async () => {
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => null,
correlate: async () => null,
});
await service.start();
probe.next = [{ executable: "zoom.us", pid: 100 }];
await detector.tick();
await detector.tick();
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(1);
});
it("respects per-app mute", async () => {
await suppression.init();
await suppression.muteApp("Discord");
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => null,
correlate: async () => null,
});
await service.start();
probe.next = [{ executable: "Discord", pid: 300 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(0);
});
});

View file

@ -0,0 +1,153 @@
import type { INotificationService } from "@x/core/dist/application/notification/service.js";
import { MeetingDetector, type MeetingActiveEvent } from "./detector.js";
import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.js";
import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js";
import { Suppression } from "./suppression.js";
import type { MeetingAppKind } from "./meeting-apps.js";
// Glue layer: turns detector events into popup notifications, gated by browser
// tab matching, calendar correlation, and the suppression store.
//
// Tests inject their own detector + notification service + suppression so this
// runs without touching the OS.
type Matcher = () => Promise<BrowserMeetingMatch | null>;
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
export interface MeetingDetectServiceOptions {
detector: MeetingDetector;
notifier: INotificationService;
suppression: Suppression;
// Defaults run the real OS-touching versions; tests override.
matchBrowser?: Matcher;
correlate?: Correlator;
}
export class MeetingDetectService {
private readonly detector: MeetingDetector;
private readonly notifier: INotificationService;
private readonly suppression: Suppression;
private readonly matchBrowser: Matcher;
private readonly correlate: Correlator;
// Track async work spawned from detector events so tests (and shutdown)
// can wait for it to settle.
private pending = new Set<Promise<void>>();
constructor(opts: MeetingDetectServiceOptions) {
this.detector = opts.detector;
this.notifier = opts.notifier;
this.suppression = opts.suppression;
this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting;
this.correlate = opts.correlate ?? ((now) => correlateNow(now));
}
async start(): Promise<void> {
await this.suppression.init();
if (!this.notifier.isSupported()) {
console.warn("[MeetingDetect] notification service unsupported; detector will run but no popups will fire");
}
this.detector.on("meeting-active", (event) => {
const work = this.handleActive(event).catch((err) => {
console.error("[MeetingDetect] handleActive failed:", err);
});
this.pending.add(work);
void work.finally(() => this.pending.delete(work));
});
this.detector.start();
}
stop(): void {
this.detector.stop();
}
/** Test hook — resolves once all in-flight handleActive() calls complete. */
async settle(): Promise<void> {
while (this.pending.size > 0) {
await Promise.all([...this.pending]);
}
}
private async handleActive(event: MeetingActiveEvent): Promise<void> {
if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) return;
// For browsers we MUST confirm the foreground tab is a meeting page —
// otherwise we'd popup for YouTube, Spotify web, etc.
let browserMatch: BrowserMeetingMatch | null = null;
if (event.kind === "browser") {
browserMatch = await this.matchBrowser();
if (!browserMatch) return;
}
const correlated = await this.correlate(new Date()).catch(() => null);
const payload = buildPopup(event.kind, browserMatch, correlated);
if (!payload) return;
try {
this.notifier.notify(payload.notify);
await this.suppression.markNotified(event.sessionKey);
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
} catch (err) {
console.error("[MeetingDetect] notify failed:", err);
}
}
}
interface BuiltPopup {
notify: {
title: string;
message: string;
link: string;
actionLabel: string;
};
}
export function buildPopup(
kind: MeetingAppKind,
browserMatch: BrowserMeetingMatch | null,
correlated: CorrelatedEvent | null,
): BuiltPopup | null {
const platformLabel = describePlatform(kind, browserMatch);
if (!platformLabel) return null;
if (correlated) {
return {
notify: {
title: "Take notes for this meeting?",
message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`,
link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`,
actionLabel: "Take notes",
},
};
}
// Ad-hoc — no calendar event matched. Still offer notes, with generic copy.
return {
notify: {
title: "You're in a meeting",
message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`,
link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(`Ad-hoc ${platformLabel} call`)}`,
actionLabel: "Take notes",
},
};
}
function describePlatform(kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null): string | null {
if (browserMatch) {
switch (browserMatch.platform) {
case "google-meet": return "Google Meet";
case "zoom-web": return "Zoom";
case "teams-web": return "Microsoft Teams";
case "slack-huddle": return "Slack huddle";
case "webex-web": return "Webex";
}
}
switch (kind) {
case "zoom": return "Zoom";
case "teams": return "Microsoft Teams";
case "slack": return "Slack";
case "discord": return "Discord";
case "webex": return "Webex";
case "browser": return null; // shouldn't happen — caller bails before us when no browserMatch
case "unknown": return null;
}
}

View file

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Suppression, InMemorySuppressionStore } from "./suppression.js";
describe("Suppression", () => {
let store: InMemorySuppressionStore;
let suppression: Suppression;
beforeEach(async () => {
store = new InMemorySuppressionStore();
suppression = new Suppression(store);
await suppression.init();
});
it("allows the first popup for a fresh session", () => {
expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(true);
});
it("blocks re-popup for the same session once marked notified", async () => {
await suppression.markNotified("zoom.us#100");
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("respects the dismiss cooldown window", async () => {
const t0 = new Date("2026-05-15T10:00:00Z");
await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0);
const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later
expect(suppression.shouldNotify("zoom.us#200", "zoom.us", within)).toBe(false);
const after = new Date(t0.getTime() + 31 * 60 * 1000); // 31 min later — past 30-min cooldown
// Cooldown GC drops entries past the window — re-load to apply GC.
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("zoom.us#200", "zoom.us", after)).toBe(true);
});
it("permanently mutes an app", async () => {
await suppression.muteApp("/Applications/Discord.app/Contents/MacOS/Discord");
expect(suppression.shouldNotify("Discord#9", "Discord")).toBe(false);
// And after reload, still muted.
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("Discord#10", "Discord")).toBe(false);
});
it("persists state through save/load", async () => {
await suppression.markNotified("zoom.us#100");
await suppression.muteApp("Discord");
const snap = store.snapshot();
expect(snap.notifiedSessions["zoom.us#100"]).toBeDefined();
expect(snap.mutedApps).toContain("discord");
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
expect(reloaded.isMuted("Discord")).toBe(true);
});
it("dismiss key normalizes path differences (Win path vs basename)", async () => {
const winPath = "C:\\Program Files\\Zoom\\bin\\Zoom.exe";
const macPath = "/Applications/Zoom.app/Contents/MacOS/zoom.us";
// Mute via mac-style path, expect it to apply when the detector reports the Windows-style path
// only if the basename matches. zoom.exe vs zoom.us differ, so they should NOT cross-match
// — verifying the dismiss key is the bare exe name and we don't over-match.
await suppression.muteApp(winPath);
expect(suppression.isMuted(winPath)).toBe(true);
expect(suppression.isMuted(macPath)).toBe(false);
});
});

View file

@ -0,0 +1,151 @@
import path from "node:path";
import fs from "node:fs/promises";
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;
// 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 }>;
// User explicitly dismissed for this exe at this time.
recentlyDismissed: Record<string, { dismissedAt: string }>;
// Permanent "never offer for this app" list — exe substring matches.
mutedApps: string[];
}
function empty(): SuppressionState {
return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] };
}
export interface SuppressionStore {
load(): Promise<SuppressionState>;
save(state: SuppressionState): Promise<void>;
}
class FileSuppressionStore implements SuppressionStore {
private readonly file: string;
constructor(file: string) { this.file = file; }
async load(): Promise<SuppressionState> {
try {
const raw = await fs.readFile(this.file, "utf-8");
const parsed = JSON.parse(raw);
return normalize(parsed);
} catch {
return empty();
}
}
async save(state: SuppressionState): Promise<void> {
const tmp = `${this.file}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, this.file);
}
}
function normalize(raw: unknown): SuppressionState {
if (!raw || typeof raw !== "object") return empty();
const obj = raw as Partial<SuppressionState>;
return {
notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {},
recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {},
mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [],
};
}
export class Suppression {
private readonly store: SuppressionStore;
private state: SuppressionState = empty();
private loaded = false;
constructor(store?: SuppressionStore) {
this.store = store ?? new FileSuppressionStore(STATE_FILE);
}
async init(): Promise<void> {
this.state = gc(await this.store.load());
this.loaded = true;
}
/** Should we fire a popup for this (sessionKey, executable)? */
shouldNotify(sessionKey: string, executable: string, now: Date = new Date()): boolean {
if (!this.loaded) return true; // fail open — better to occasionally re-popup than to silently miss.
if (this.isMuted(executable)) return false;
if (this.state.notifiedSessions[sessionKey]) return false;
const dismissKey = dismissKeyFor(executable);
const recent = this.state.recentlyDismissed[dismissKey];
if (recent) {
const dismissedAt = Date.parse(recent.dismissedAt);
if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) {
return false;
}
}
return true;
}
async markNotified(sessionKey: string, now: Date = new Date()): Promise<void> {
this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() };
await this.persist();
}
async markDismissed(executable: string, now: Date = new Date()): Promise<void> {
this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() };
await this.persist();
}
async muteApp(executable: string): Promise<void> {
const key = dismissKeyFor(executable);
if (!this.state.mutedApps.includes(key)) {
this.state.mutedApps.push(key);
await this.persist();
}
}
isMuted(executable: string): boolean {
const needle = dismissKeyFor(executable);
return this.state.mutedApps.some((m) => needle.includes(m) || m.includes(needle));
}
private async persist(): Promise<void> {
this.state = gc(this.state);
try {
await this.store.save(this.state);
} catch (err) {
console.error("[MeetingDetect] failed to persist suppression state:", err);
}
}
}
function dismissKeyFor(executable: string): string {
// Reduce a path/exe to a stable key — strip directory, lowercase.
const base = executable.replace(/^.*[/\\]/, "").toLowerCase();
return base || executable.toLowerCase();
}
function gc(state: SuppressionState): SuppressionState {
const now = Date.now();
const sessions: SuppressionState["notifiedSessions"] = {};
for (const [k, v] of Object.entries(state.notifiedSessions)) {
const ts = Date.parse(v.notifiedAt);
if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[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 };
}
/** In-memory store for tests. */
export class InMemorySuppressionStore implements SuppressionStore {
private state: SuppressionState = empty();
async load(): Promise<SuppressionState> { return JSON.parse(JSON.stringify(this.state)); }
async save(s: SuppressionState): Promise<void> { this.state = JSON.parse(JSON.stringify(s)); }
snapshot(): SuppressionState { return JSON.parse(JSON.stringify(this.state)); }
}

View file

@ -0,0 +1,12 @@
export interface MicUser {
// Best-effort executable identifier — full path on Windows, command name on macOS.
executable: string;
// Process id when the platform exposes it (macOS via lsof). Undefined on Windows
// because the registry only records the exe path, not which pid is currently
// holding the mic.
pid?: number;
}
export interface MicProbe {
probe(): Promise<MicUser[]>;
}

View file

@ -10,5 +10,8 @@
},
"include": [
"src"
],
"exclude": [
"src/**/*.test.ts"
]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

520
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff