mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
feat: detect mic-in-use meetings and prompt for note-taking
This commit is contained in:
parent
d981fa9206
commit
6c9d9206c8
22 changed files with 2031 additions and 17 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
44
apps/x/apps/main/src/meeting-detect/browser-match.test.ts
Normal file
44
apps/x/apps/main/src/meeting-detect/browser-match.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
63
apps/x/apps/main/src/meeting-detect/browser-match.ts
Normal file
63
apps/x/apps/main/src/meeting-detect/browser-match.ts
Normal 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;
|
||||
}
|
||||
111
apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts
Normal file
111
apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
123
apps/x/apps/main/src/meeting-detect/calendar-correlate.ts
Normal file
123
apps/x/apps/main/src/meeting-detect/calendar-correlate.ts
Normal 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;
|
||||
}
|
||||
134
apps/x/apps/main/src/meeting-detect/detector.test.ts
Normal file
134
apps/x/apps/main/src/meeting-detect/detector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
107
apps/x/apps/main/src/meeting-detect/detector.ts
Normal file
107
apps/x/apps/main/src/meeting-detect/detector.ts
Normal 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;
|
||||
}
|
||||
97
apps/x/apps/main/src/meeting-detect/foreground-window.ts
Normal file
97
apps/x/apps/main/src/meeting-detect/foreground-window.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
apps/x/apps/main/src/meeting-detect/index.ts
Normal file
26
apps/x/apps/main/src/meeting-detect/index.ts
Normal 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;
|
||||
}
|
||||
49
apps/x/apps/main/src/meeting-detect/meeting-apps.ts
Normal file
49
apps/x/apps/main/src/meeting-detect/meeting-apps.ts
Normal 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";
|
||||
}
|
||||
47
apps/x/apps/main/src/meeting-detect/probe-macos.ts
Normal file
47
apps/x/apps/main/src/meeting-detect/probe-macos.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
85
apps/x/apps/main/src/meeting-detect/probe-windows.ts
Normal file
85
apps/x/apps/main/src/meeting-detect/probe-windows.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
166
apps/x/apps/main/src/meeting-detect/service.test.ts
Normal file
166
apps/x/apps/main/src/meeting-detect/service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
apps/x/apps/main/src/meeting-detect/service.ts
Normal file
153
apps/x/apps/main/src/meeting-detect/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
apps/x/apps/main/src/meeting-detect/suppression.test.ts
Normal file
76
apps/x/apps/main/src/meeting-detect/suppression.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
151
apps/x/apps/main/src/meeting-detect/suppression.ts
Normal file
151
apps/x/apps/main/src/meeting-detect/suppression.ts
Normal 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)); }
|
||||
}
|
||||
12
apps/x/apps/main/src/meeting-detect/types.ts
Normal file
12
apps/x/apps/main/src/meeting-detect/types.ts
Normal 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[]>;
|
||||
}
|
||||
|
|
@ -10,5 +10,8 @@
|
|||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
8
apps/x/apps/main/vitest.config.ts
Normal file
8
apps/x/apps/main/vitest.config.ts
Normal 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
520
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue