From 6c9d9206c88be14edb268420809adf2604bb2fb7 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 14:06:51 +0530 Subject: [PATCH 1/6] feat: detect mic-in-use meetings and prompt for note-taking --- apps/x/apps/main/package.json | 6 +- apps/x/apps/main/src/deeplink.ts | 44 +- apps/x/apps/main/src/main.ts | 23 +- .../src/meeting-detect/browser-match.test.ts | 44 ++ .../main/src/meeting-detect/browser-match.ts | 63 +++ .../meeting-detect/calendar-correlate.test.ts | 111 ++++ .../src/meeting-detect/calendar-correlate.ts | 123 +++++ .../main/src/meeting-detect/detector.test.ts | 134 +++++ .../apps/main/src/meeting-detect/detector.ts | 107 ++++ .../src/meeting-detect/foreground-window.ts | 97 ++++ apps/x/apps/main/src/meeting-detect/index.ts | 26 + .../main/src/meeting-detect/meeting-apps.ts | 49 ++ .../main/src/meeting-detect/probe-macos.ts | 47 ++ .../main/src/meeting-detect/probe-windows.ts | 85 +++ .../main/src/meeting-detect/service.test.ts | 166 ++++++ .../x/apps/main/src/meeting-detect/service.ts | 153 ++++++ .../src/meeting-detect/suppression.test.ts | 76 +++ .../main/src/meeting-detect/suppression.ts | 151 +++++ apps/x/apps/main/src/meeting-detect/types.ts | 12 + apps/x/apps/main/tsconfig.json | 3 + apps/x/apps/main/vitest.config.ts | 8 + apps/x/pnpm-lock.yaml | 520 ++++++++++++++++++ 22 files changed, 2031 insertions(+), 17 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/browser-match.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/browser-match.ts create mode 100644 apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/calendar-correlate.ts create mode 100644 apps/x/apps/main/src/meeting-detect/detector.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/detector.ts create mode 100644 apps/x/apps/main/src/meeting-detect/foreground-window.ts create mode 100644 apps/x/apps/main/src/meeting-detect/index.ts create mode 100644 apps/x/apps/main/src/meeting-detect/meeting-apps.ts create mode 100644 apps/x/apps/main/src/meeting-detect/probe-macos.ts create mode 100644 apps/x/apps/main/src/meeting-detect/probe-windows.ts create mode 100644 apps/x/apps/main/src/meeting-detect/service.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/service.ts create mode 100644 apps/x/apps/main/src/meeting-detect/suppression.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/suppression.ts create mode 100644 apps/x/apps/main/src/meeting-detect/types.ts create mode 100644 apps/x/apps/main/vitest.config.ts diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..de3f462e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -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" } } \ No newline at end of file diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index aaaaa3bc..9e058fd9 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -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 { 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 { +async function handleTakeMeetingNotes( + eventId: string | undefined, + title: string | undefined, + openMeeting: boolean, +): Promise { 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", () => { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..a5e9eb5f 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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(); diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.test.ts b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts new file mode 100644 index 00000000..6fbc80bf --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts @@ -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"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts new file mode 100644 index 00000000..efbf829f --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -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: " | 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 { + 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; +} diff --git a/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts b/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts new file mode 100644 index 00000000..5772ea56 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts b/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts new file mode 100644 index 00000000..67f7b35e --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/x/apps/main/src/meeting-detect/detector.test.ts b/apps/x/apps/main/src/meeting-detect/detector.test.ts new file mode 100644 index 00000000..b161789c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/detector.test.ts @@ -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 { 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(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/detector.ts b/apps/x/apps/main/src/meeting-detect/detector.ts new file mode 100644 index 00000000..58e95613 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/detector.ts @@ -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(); + 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 { + const users = await this.probe.probe(); + const seenKeys = new Set(); + 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; +} diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts new file mode 100644 index 00000000..72c1c8ce --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/index.ts b/apps/x/apps/main/src/meeting-detect/index.ts new file mode 100644 index 00000000..9f7c2fe7 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/index.ts @@ -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; +} diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts new file mode 100644 index 00000000..a18dec0f --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts @@ -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"; +} diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.ts new file mode 100644 index 00000000..bc2fddd2 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.ts @@ -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 { + 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(); + 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()); + } +} diff --git a/apps/x/apps/main/src/meeting-detect/probe-windows.ts b/apps/x/apps/main/src/meeting-detect/probe-windows.ts new file mode 100644 index 00000000..08af5ccc --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/probe-windows.ts @@ -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 { + 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(); + 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; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts new file mode 100644 index 00000000..870ddb9b --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -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 { 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); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts new file mode 100644 index 00000000..82a19824 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -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; +type Correlator = (now: Date) => Promise; + +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>(); + + 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 { + 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 { + while (this.pending.size > 0) { + await Promise.all([...this.pending]); + } + } + + private async handleActive(event: MeetingActiveEvent): Promise { + 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; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts new file mode 100644 index 00000000..ba569fdd --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -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); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts new file mode 100644 index 00000000..cdea3648 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -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; + // User explicitly dismissed for this exe at this time. + recentlyDismissed: Record; + // Permanent "never offer for this app" list — exe substring matches. + mutedApps: string[]; +} + +function empty(): SuppressionState { + return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] }; +} + +export interface SuppressionStore { + load(): Promise; + save(state: SuppressionState): Promise; +} + +class FileSuppressionStore implements SuppressionStore { + private readonly file: string; + constructor(file: string) { this.file = file; } + + async load(): Promise { + 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 { + 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; + 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 { + 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 { + this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() }; + await this.persist(); + } + + async markDismissed(executable: string, now: Date = new Date()): Promise { + this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() }; + await this.persist(); + } + + async muteApp(executable: string): Promise { + 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 { + 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 { return JSON.parse(JSON.stringify(this.state)); } + async save(s: SuppressionState): Promise { this.state = JSON.parse(JSON.stringify(s)); } + snapshot(): SuppressionState { return JSON.parse(JSON.stringify(this.state)); } +} diff --git a/apps/x/apps/main/src/meeting-detect/types.ts b/apps/x/apps/main/src/meeting-detect/types.ts new file mode 100644 index 00000000..c4420197 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/types.ts @@ -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; +} diff --git a/apps/x/apps/main/tsconfig.json b/apps/x/apps/main/tsconfig.json index d7166cc7..fbfc3525 100644 --- a/apps/x/apps/main/tsconfig.json +++ b/apps/x/apps/main/tsconfig.json @@ -10,5 +10,8 @@ }, "include": [ "src" + ], + "exclude": [ + "src/**/*.test.ts" ] } \ No newline at end of file diff --git a/apps/x/apps/main/vitest.config.ts b/apps/x/apps/main/vitest.config.ts new file mode 100644 index 00000000..7eeb3f85 --- /dev/null +++ b/apps/x/apps/main/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 0605adaf..b783403c 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vitest: + specifier: 4.1.7 + version: 4.1.7 + importers: .: @@ -111,6 +117,9 @@ importers: esbuild: specifier: ^0.24.2 version: 0.24.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) apps/preload: dependencies: @@ -927,6 +936,12 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -939,6 +954,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} @@ -951,6 +972,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} @@ -963,6 +990,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} @@ -975,6 +1008,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} @@ -987,6 +1026,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} @@ -999,6 +1044,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} @@ -1011,6 +1062,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} @@ -1023,6 +1080,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} @@ -1035,6 +1098,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} @@ -1047,6 +1116,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} @@ -1059,6 +1134,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} @@ -1071,6 +1152,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} @@ -1083,6 +1170,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} @@ -1095,6 +1188,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} @@ -1107,6 +1206,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} @@ -1119,6 +1224,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -1143,6 +1254,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} @@ -1167,6 +1284,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} @@ -1185,6 +1308,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} @@ -1197,6 +1326,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} @@ -1209,6 +1344,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} @@ -1221,6 +1362,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -3581,9 +3728,23 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.7': resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: @@ -3595,18 +3756,33 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.7': resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.7': resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.7': resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.7': resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} @@ -3938,6 +4114,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@16.1.3: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3982,6 +4162,10 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -4005,6 +4189,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain-allstar@0.4.1: resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} peerDependencies: @@ -4411,6 +4599,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4623,6 +4815,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -4640,6 +4835,11 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -5753,6 +5953,9 @@ packages: lop@0.4.2: resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -6461,9 +6664,16 @@ packages: resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} engines: {node: '>=4'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pdf-parse@2.4.5: resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} engines: {node: '>=20.16.0 <21 || >=22.3.0'} @@ -7165,6 +7375,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -7312,6 +7525,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -7320,10 +7536,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tiptap-markdown@0.9.0: resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} peerDependencies: @@ -7598,6 +7826,42 @@ packages: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7638,6 +7902,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.7: resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8994,102 +9283,153 @@ snapshots: transitivePeerDependencies: - supports-color + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.24.2': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.24.2': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true @@ -9102,6 +9442,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true @@ -9114,6 +9457,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true @@ -9123,24 +9469,36 @@ snapshots: '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true @@ -11818,6 +12176,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -11827,6 +12192,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.7 @@ -11835,15 +12208,30 @@ snapshots: optionalDependencies: vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + '@vitest/runner@4.1.7': dependencies: '@vitest/utils': 4.1.7 pathe: 2.0.3 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -11851,8 +12239,18 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@4.1.7': {} + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vitest/utils@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -12218,6 +12616,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + cacache@16.1.3: dependencies: '@npmcli/fs': 2.1.2 @@ -12288,6 +12688,14 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -12305,6 +12713,8 @@ snapshots: chardet@0.7.0: {} + check-error@2.1.3: {} + chevrotain-allstar@0.4.1(chevrotain@12.0.0): dependencies: chevrotain: 12.0.0 @@ -12708,6 +13118,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -12970,6 +13382,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -12988,6 +13402,32 @@ snapshots: es6-error@4.1.1: optional: true + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -14361,6 +14801,8 @@ snapshots: option: 0.2.4 underscore: 1.13.8 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -15299,8 +15741,12 @@ snapshots: dependencies: pify: 2.3.0 + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + pdf-parse@2.4.5: dependencies: '@napi-rs/canvas': 0.1.80 @@ -16199,6 +16645,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.1.0: {} stream-browserify@3.0.0: @@ -16369,6 +16817,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -16376,8 +16826,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} + tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)): dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -16676,6 +17132,35 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + vite-node@2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.54.0 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + lightningcss: 1.30.2 + terser: 5.46.0 + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -16708,6 +17193,41 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitest@2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + vite-node: 2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.7 From 8da40bd9bb1f486d1ac7fa445673bafd20ca91a8 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 14:41:18 +0530 Subject: [PATCH 2/6] feat: wire mic-detect popup into note-taking flow --- .../x/apps/main/src/meeting-detect/service.ts | 15 +++- .../main/src/meeting-detect/suppression.ts | 12 ++++ apps/x/apps/renderer/src/App.tsx | 71 ++++++++++++------- apps/x/packages/shared/src/ipc.ts | 4 +- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 82a19824..91080ee0 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -53,7 +53,16 @@ export class MeetingDetectService { this.pending.add(work); void work.finally(() => this.pending.delete(work)); }); + this.detector.on("meeting-cleared", (event) => { + // Mic released → drop the session's suppression so the next call + // (same Chrome process, new Meet) can fire again. + this.suppression.clearSession(event.sessionKey).catch((err) => { + console.error("[MeetingDetect] clearSession failed:", err); + }); + console.log(`[MeetingDetect] session cleared: ${event.sessionKey}`); + }); this.detector.start(); + console.log("[MeetingDetect] service started — polling for meeting apps holding the mic"); } stop(): void { @@ -68,7 +77,11 @@ export class MeetingDetectService { } private async handleActive(event: MeetingActiveEvent): Promise { - if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) return; + console.log(`[MeetingDetect] active: ${event.executable} (kind=${event.kind})`); + if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) { + console.log(`[MeetingDetect] suppressed (already notified or muted): ${event.sessionKey}`); + return; + } // For browsers we MUST confirm the foreground tab is a meeting page — // otherwise we'd popup for YouTube, Spotify web, etc. diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts index cdea3648..c2b42ece 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -93,6 +93,18 @@ export class Suppression { await this.persist(); } + /** + * Clear the notified mark for a session. Called when the detector observes + * the mic being released — without this, on Windows (no pid in sessionKey) + * the same browser would never re-fire because every new Meet call reuses + * the same exe-keyed session. + */ + async clearSession(sessionKey: string): Promise { + if (!this.state.notifiedSessions[sessionKey]) return; + delete this.state.notifiedSessions[sessionKey]; + await this.persist(); + } + async markDismissed(executable: string, now: Date = new Date()): Promise { this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() }; await this.persist(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..c6184e47 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3968,36 +3968,53 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) - // Triggered by main when the user clicks a calendar-meeting notification. - // Reuses the same flow as the in-app "Join meeting & take notes" button. + // Triggered by main when the user clicks a meeting-notes notification — + // either the calendar-time notification (event populated) or the mic-detect + // ad-hoc notification (event=null, title=string). Both routes feed the same + // calendar-block flow which kicks off startMeetingNow(). // When `openMeeting` is true, also opens the meeting URL in the system browser. useEffect(() => { - return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { - const e = event as { - summary?: string - start?: { dateTime?: string; date?: string; timeZone?: string } - end?: { dateTime?: string; date?: string; timeZone?: string } - location?: string - htmlLink?: string - hangoutLink?: string - conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } - } - if (!e || typeof e !== 'object') return - const conferenceLink = extractConferenceLink(e as Record) - if (openMeeting && conferenceLink) { - window.open(conferenceLink, '_blank') - } else if (openMeeting) { - console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) - } - window.__pendingCalendarEvent = { - summary: e.summary, - start: e.start, - end: e.end, - location: e.location, - htmlLink: e.htmlLink, - conferenceLink, - source: 'calendar-sync', + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, title }) => { + const payload = event as + | { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + | null + | undefined + + if (payload && typeof payload === 'object') { + const conferenceLink = extractConferenceLink(payload as Record) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', payload) + } + window.__pendingCalendarEvent = { + summary: payload.summary, + start: payload.start, + end: payload.end, + location: payload.location, + htmlLink: payload.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + } else if (typeof title === 'string' && title.length > 0) { + // Ad-hoc detection — no calendar event matched. Build a minimal + // pending event from the title so the meeting flow can still start. + window.__pendingCalendarEvent = { + summary: title, + source: 'meeting-detect', + } + } else { + return } + window.dispatchEvent(new Event('calendar-block:join-meeting')) }) }, []) diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 230d384c..f61aae7e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -395,11 +395,13 @@ const ipcSchemas = { }, 'app:takeMeetingNotes': { req: z.object({ - // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + // Calendar event JSON when correlated; null for mic-detect ad-hoc fires. event: z.unknown(), // When true, the renderer should also open the meeting URL (Zoom/Meet/etc.) // in addition to triggering the take-notes flow. openMeeting: z.boolean().optional(), + // Fallback title for ad-hoc detection (no calendar event matched). + title: z.string().nullable().optional(), }), res: z.null(), }, From 2901379d2338bdbc8294a9b8a61b2d64e9f0cf3a Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:30:50 +0530 Subject: [PATCH 3/6] feat: name ad-hoc meeting notes by platform with same-day counter --- .../src/meeting-detect/ad-hoc-title.test.ts | 89 +++++++++++++++ .../main/src/meeting-detect/ad-hoc-title.ts | 101 ++++++++++++++++++ .../main/src/meeting-detect/browser-match.ts | 19 ++-- .../src/meeting-detect/foreground-window.ts | 99 +++++++++-------- .../main/src/meeting-detect/service.test.ts | 7 ++ .../x/apps/main/src/meeting-detect/service.ts | 33 +++++- 6 files changed, 284 insertions(+), 64 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts new file mode 100644 index 00000000..97bbf92b --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts @@ -0,0 +1,89 @@ +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 { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; + +let tmpRoot: string; +const NOW = new Date(2026, 4, 15, 14, 0, 0); // 2026-05-15 14:00 local + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rb-adhoc-title-")); +}); + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); +}); + +async function writeNote(day: string, filename: string): Promise { + const dir = path.join(tmpRoot, day); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, filename), "stub", "utf-8"); +} + +describe("buildAdHocTitle", () => { + it("returns the bare title for the first occurrence of the day", async () => { + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("appends #2 when one already exists", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #2"); + }); + + it("increments past #2 (#3, #4, ...)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#2.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#3.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #4"); + }); + + it("doesn't cross-count platforms (Meet vs Zoom stay distinct)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Meet", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Meet"); + }); + + it("resets the counter on a different day", async () => { + await writeNote("2026-05-14", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("ignores non-meeting notes in the same folder", async () => { + await writeNote("2026-05-15", "standup.md"); + await writeNote("2026-05-15", "random_note.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("matches slug-variant filenames (different separators)", async () => { + // Whatever the renderer's slugifier does, normalize() should match. + await writeNote("2026-05-15", "Meeting Notes - Zoom.md"); + await writeNote("2026-05-15", "Meeting-Notes--Zoom.md"); // hypothetical alt slug + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #3"); + }); +}); + +describe("shortPlatformLabel", () => { + it("maps browser platforms to short labels", () => { + expect(shortPlatformLabel({ browserPlatform: "google-meet", kind: "browser" })).toBe("Meet"); + expect(shortPlatformLabel({ browserPlatform: "zoom-web", kind: "browser" })).toBe("Zoom"); + expect(shortPlatformLabel({ browserPlatform: "teams-web", kind: "browser" })).toBe("Teams"); + }); + + it("maps native kinds to short labels", () => { + expect(shortPlatformLabel({ kind: "zoom" })).toBe("Zoom"); + expect(shortPlatformLabel({ kind: "teams" })).toBe("Teams"); + expect(shortPlatformLabel({ kind: "discord" })).toBe("Discord"); + }); + + it("returns null for unmatched browser / unknown", () => { + expect(shortPlatformLabel({ kind: "browser" })).toBeNull(); + expect(shortPlatformLabel({ kind: "unknown" })).toBeNull(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts new file mode 100644 index 00000000..e71baaf7 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts @@ -0,0 +1,101 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +// Ad-hoc meeting titles: "Meeting Notes - " with a per-day counter +// suffix when there's already one for the same platform on the same day. +// +// first Zoom today → "Meeting Notes - Zoom" +// second Zoom today → "Meeting Notes - Zoom #2" +// first Zoom tomorrow → "Meeting Notes - Zoom" (fresh folder, fresh count) + +const MEETINGS_ROOT = path.join(WorkDir, "knowledge", "Meetings", "rowboat"); +const TITLE_PREFIX = "Meeting Notes - "; + +export interface AdHocTitleOptions { + platformLabel: string; + now?: Date; + // Override for tests; defaults to the user's real meetings folder. + root?: string; +} + +export async function buildAdHocTitle(opts: AdHocTitleOptions): Promise { + const platform = opts.platformLabel; + const base = `${TITLE_PREFIX}${platform}`; + + const now = opts.now ?? new Date(); + const dayFolder = path.join(opts.root ?? MEETINGS_ROOT, formatDay(now)); + + const existing = await countMatching(dayFolder, base); + if (existing === 0) return base; + return `${base} #${existing + 1}`; +} + +function formatDay(d: Date): string { + // YYYY-MM-DD in local time — matches the existing knowledge/Meetings/rowboat layout. + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +async function countMatching(dir: string, baseTitle: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return 0; + } + const needle = normalize(baseTitle); + let count = 0; + for (const name of entries) { + if (!name.endsWith(".md")) continue; + const stem = name.slice(0, -3); // strip .md + if (normalize(stem).startsWith(needle)) count++; + } + return count; +} + +/** + * Normalize a title or filename to alphanumerics-only-lowercase so we can + * compare across slugification rules: + * "Meeting Notes - Zoom" → "meetingnoteszoom" + * "Meeting_Notes_-_Zoom.md" → "meetingnoteszoom" (after .md strip) + * "Meeting Notes - Zoom #2" → "meetingnoteszoom2" + * + * Anchoring with startsWith() then catches both the bare title and any + * counter-suffixed variant, without colliding across platforms ("Meet" + * vs "Zoom" stay distinct because the platform name appears after the + * common "meetingnotes" prefix). + */ +function normalize(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +// Map our internal platform/kind names to user-facing short labels. +// Re-exported so service.ts can produce both the popup body label and the +// note title from the same source of truth. +export function shortPlatformLabel(input: { + browserPlatform?: "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; + kind: "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown"; +}): string | null { + if (input.browserPlatform) { + switch (input.browserPlatform) { + case "google-meet": return "Meet"; + case "zoom-web": return "Zoom"; + case "teams-web": return "Teams"; + case "slack-huddle": return "Slack"; + case "webex-web": return "Webex"; + } + } + switch (input.kind) { + case "zoom": return "Zoom"; + case "teams": return "Teams"; + case "slack": return "Slack"; + case "discord": return "Discord"; + case "webex": return "Webex"; + case "browser": + case "unknown": + return null; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts index efbf829f..6de404f9 100644 --- a/apps/x/apps/main/src/meeting-detect/browser-match.ts +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -1,4 +1,4 @@ -import { getForegroundWindow } from "./foreground-window.js"; +import { getWindowSnapshot } from "./foreground-window.js"; export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; @@ -36,13 +36,16 @@ const RULES: TitleRule[] = [ * 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 { - 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); +export async function matchBrowserMeeting(executable?: string): Promise { + const snap = await getWindowSnapshot(executable); + if (!snap) return null; + // Scan ALL known window titles — on Windows tasklist returns every window, + // so even a backgrounded Meet tab still matches while Chrome holds the mic. + for (const title of snap.titles) { + const m = matchTitleOrUrl(title, undefined); + if (m) return m; + } + return null; } /** Pure matcher — exposed for tests; no OS calls. */ diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index 72c1c8ce..e9f523d0 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -3,70 +3,66 @@ 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; +export interface WindowSnapshot { + // Window titles we know about. Implementations may return one (foreground) + // or many (all titles for a process). browser-match scans the whole list, + // so we don't need to identify which is foreground. + titles: 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. + * Best-effort look at currently-open window titles for a given executable. + * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips + * every system process. On macOS: AppleScript for the frontmost window. * - * We dropped `active-win` because its prebuilt native binary depends on - * runtime package.json lookups that don't survive esbuild bundling. + * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; + * an empty title list means "process is running but no window has a title." */ -export async function getForegroundWindow(): Promise { - if (process.platform === "win32") return getForegroundWindowWindows(); - if (process.platform === "darwin") return getForegroundWindowMacOS(); +export async function getWindowSnapshot(executable?: string): Promise { + if (process.platform === "win32") return getWindowSnapshotWindows(executable); + if (process.platform === "darwin") return getWindowSnapshotMacOS(); 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 getWindowSnapshotWindows(executable?: string): Promise { + // Reduce to a basename — full paths can't be passed to tasklist's + // imagename filter, and the filter wants e.g. "chrome.exe", not the path. + const imageName = executable ? executable.replace(/^.*[\\/]/, "") : ""; + const args = ["/v", "/fo", "csv", "/nh"]; + if (imageName) args.push("/fi", `imagename eq ${imageName}`); -async function getForegroundWindowWindows(): Promise { try { const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], - { timeout: 5_000, windowsHide: true }, + "tasklist.exe", + args, + { timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, ); - 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 }; + const titles: string[] = []; + for (const line of stdout.split(/\r?\n/)) { + if (!line) continue; + const fields = parseCsvLine(line); + if (fields.length === 0) continue; + const title = fields[fields.length - 1]; + if (!title || title === "N/A") continue; + titles.push(title); + } + return { titles }; } catch (err) { - console.error("[MeetingDetect] foreground-window (windows) failed:", err); + console.error("[MeetingDetect] window-snapshot (windows) failed:", err); return null; } } +function parseCsvLine(line: string): string[] { + // tasklist /fo csv quotes every field and doesn't embed quotes within fields, + // so a simple comma-split between quoted segments works. + const out: string[] = []; + const re = /"([^"]*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) out.push(m[1]); + return out; +} + // 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. @@ -83,15 +79,16 @@ tell application "System Events" end tell `.trim(); -async function getForegroundWindowMacOS(): Promise { +async function getWindowSnapshotMacOS(): Promise { 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 }; + const [, ...titleParts] = stdout.trim().split("\n"); + const title = titleParts.join("\n"); + return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] foreground-window (macOS) failed:", err); + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 870ddb9b..7586efde 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -37,6 +37,13 @@ describe("buildPopup", () => { expect(popup?.notify.title).toBe("You're in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); + // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom"); + }); + + it("uses the precomputed ad-hoc title when provided (counter case)", () => { + const popup = buildPopup("zoom", null, null, "Meeting Notes - Zoom #2"); + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom #2"); }); it("uses browser match platform label when kind=browser", () => { diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 91080ee0..5e38c521 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -4,6 +4,7 @@ import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.j import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; +import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -11,7 +12,7 @@ import type { MeetingAppKind } from "./meeting-apps.js"; // Tests inject their own detector + notification service + suppression so this // runs without touching the OS. -type Matcher = () => Promise; +type Matcher = (executable?: string) => Promise; type Correlator = (now: Date) => Promise; export interface MeetingDetectServiceOptions { @@ -87,12 +88,30 @@ export class MeetingDetectService { // otherwise we'd popup for YouTube, Spotify web, etc. let browserMatch: BrowserMeetingMatch | null = null; if (event.kind === "browser") { - browserMatch = await this.matchBrowser(); + browserMatch = await this.matchBrowser(event.executable); if (!browserMatch) return; } const correlated = await this.correlate(new Date()).catch(() => null); - const payload = buildPopup(event.kind, browserMatch, correlated); + + // Ad-hoc only: compute "Meeting Notes - [#N]" so the note + // file lands with a useful title. Skip when we have a real calendar + // event — that already provides the right summary. + let adHocTitle: string | undefined; + if (!correlated) { + const short = shortPlatformLabel({ + browserPlatform: browserMatch?.platform, + kind: event.kind, + }); + if (short) { + adHocTitle = await buildAdHocTitle({ platformLabel: short }).catch((err) => { + console.error("[MeetingDetect] buildAdHocTitle failed:", err); + return `Meeting Notes - ${short}`; + }); + } + } + + const payload = buildPopup(event.kind, browserMatch, correlated, adHocTitle); if (!payload) return; try { @@ -118,6 +137,7 @@ export function buildPopup( kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null, correlated: CorrelatedEvent | null, + adHocTitle?: string, ): BuiltPopup | null { const platformLabel = describePlatform(kind, browserMatch); if (!platformLabel) return null; @@ -133,12 +153,15 @@ export function buildPopup( }; } - // Ad-hoc — no calendar event matched. Still offer notes, with generic copy. + // Ad-hoc — no calendar event matched. Use the precomputed counter-aware + // title ("Meeting Notes - Zoom" / "... #2") if available; fall back to a + // simple platform-suffixed title. + const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; 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`)}`, + link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, actionLabel: "Take notes", }, }; From e7ea03c8d1492e71b283f3d391da7addd5380baa Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:47:00 +0530 Subject: [PATCH 4/6] feat: top-center notion-style toast for meeting-detect prompt --- apps/x/apps/main/src/main.ts | 9 + .../main/src/meeting-detect/service.test.ts | 32 ++++ .../x/apps/main/src/meeting-detect/service.ts | 22 ++- .../src/meeting-detect/toast-window.test.ts | 46 +++++ .../main/src/meeting-detect/toast-window.ts | 165 ++++++++++++++++++ 5 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index a5e9eb5f..c873a1f5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -391,12 +391,21 @@ app.whenReady().then(async () => { initCalendarNotifications(); // start meeting-detect service (mic-in-use detection -> popup asking if user wants notes) + // + // Popup style — flip this one constant to switch the meeting-detect prompt + // between the custom Notion-style top-center toast and the native OS + // notification. Doesn't affect the separate calendar 1-min warnings. + // false (default) → custom toast + // true → native OS notification + const USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT = false; + const meetingDetector = createPlatformDetector(); if (meetingDetector) { const meetingDetectService = new MeetingDetectService({ detector: meetingDetector, notifier: notificationService, suppression: new Suppression(), + toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined, }); meetingDetectService.start().catch((err) => { console.error("[MeetingDetect] failed to start:", err); diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 7586efde..6b076889 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -83,6 +83,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => correlated, + toast: null, }); await service.start(); @@ -103,6 +104,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, // browser foreground = not a meeting correlate: async () => null, + toast: null, }); await service.start(); @@ -120,6 +122,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }), correlate: async () => null, + toast: null, }); await service.start(); @@ -139,6 +142,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); @@ -151,6 +155,33 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(1); }); + it("uses the toast renderer when provided instead of the native notifier", async () => { + const calls: Array<{ title: string; message: string; actionLink: string }> = []; + const toast = { + show(p: { title: string; message: string; actionLabel: string; actionLink: string }) { + calls.push({ title: p.title, message: p.message, actionLink: p.actionLink }); + }, + }; + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => null, + toast, + }); + await service.start(); + + probe.next = [{ executable: "zoom.us", pid: 100 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(0); + expect(calls).toHaveLength(1); + expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].actionLink).toContain("take-meeting-notes"); + }); + it("respects per-app mute", async () => { await suppression.init(); await suppression.muteApp("Discord"); @@ -161,6 +192,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 5e38c521..37339825 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -5,6 +5,7 @@ import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; +import { MeetingToastWindow, type ToastPayload } from "./toast-window.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -22,6 +23,10 @@ export interface MeetingDetectServiceOptions { // Defaults run the real OS-touching versions; tests override. matchBrowser?: Matcher; correlate?: Correlator; + // Custom popup renderer. When provided (default in production), the toast + // is used instead of the native OS notification. Tests pass null to fall + // back to the notifier and assert on its calls. + toast?: { show(payload: ToastPayload): void } | null; } export class MeetingDetectService { @@ -30,6 +35,7 @@ export class MeetingDetectService { private readonly suppression: Suppression; private readonly matchBrowser: Matcher; private readonly correlate: Correlator; + private readonly toast: { show(payload: ToastPayload): void } | null; // Track async work spawned from detector events so tests (and shutdown) // can wait for it to settle. private pending = new Set>(); @@ -40,6 +46,9 @@ export class MeetingDetectService { this.suppression = opts.suppression; this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting; this.correlate = opts.correlate ?? ((now) => correlateNow(now)); + // `toast` is explicitly nullable so tests can opt out. Undefined → + // build the real one. Null → use the native notifier instead. + this.toast = opts.toast === undefined ? new MeetingToastWindow() : opts.toast; } async start(): Promise { @@ -115,11 +124,20 @@ export class MeetingDetectService { if (!payload) return; try { - this.notifier.notify(payload.notify); + if (this.toast) { + this.toast.show({ + title: payload.notify.title, + message: payload.notify.message, + actionLabel: payload.notify.actionLabel, + actionLink: payload.notify.link, + }); + } else { + 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); + console.error("[MeetingDetect] popup failed:", err); } } } diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts new file mode 100644 index 00000000..3d4c7244 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { buildToastHtml } from "./toast-window.js"; + +describe("buildToastHtml", () => { + it("renders title, message, action label, and a primary link to the rowboat deeplink", () => { + const html = buildToastHtml({ + title: "You're in a meeting", + message: "Detected on Google Meet. Click to take notes.", + actionLabel: "Take notes", + actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", + }); + + expect(html).toContain("You're in a meeting"); + expect(html).toContain("Detected on Google Meet"); + expect(html).toContain("Take notes"); + expect(html).toContain("rowboat://action?type=take-meeting-notes"); + }); + + it("includes a dismiss link the window will intercept", () => { + const html = buildToastHtml({ + title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain("rowboat-toast://dismiss"); + }); + + it("escapes HTML in title/message so a Meet titled `", + message: "& < > \" '", + actionLabel: "ok", + actionLink: "rowboat://action", + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + expect(html).toContain("& < > " '"); + }); + + it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => { + const html = buildToastHtml({ + title: "x", message: "y", actionLabel: "ok", + actionLink: `rowboat://action?title=evil"onerror=alert(1)`, + }); + expect(html).not.toContain(`"onerror=alert(1)`); + expect(html).toContain(""onerror=alert(1)"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.ts b/apps/x/apps/main/src/meeting-detect/toast-window.ts new file mode 100644 index 00000000..2b36252c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -0,0 +1,165 @@ +import { BrowserWindow, screen } from "electron"; +import { dispatchUrl } from "../deeplink.js"; + +// Custom Notion-style meeting toast: top-center frameless window with our +// own React-less HTML. We avoid the OS notification API because Windows +// and macOS both force-position native notifications (bottom-right / top- +// right respectively) and we want top-center. +// +// Lifecycle: +// show() → opens window, slides in, auto-closes at AUTO_DISMISS_MS +// action button click → navigation to rowboat:// → dispatchUrl + close +// dismiss button click → navigation to rowboat://dismiss → just close + +const TOAST_WIDTH = 460; +const TOAST_HEIGHT = 110; +const TOAST_TOP_MARGIN = 56; +const AUTO_DISMISS_MS = 30_000; + +export interface ToastPayload { + title: string; + message: string; + actionLabel: string; + actionLink: string; +} + +/** Build the self-contained HTML the toast window renders. Pure — tested. */ +export function buildToastHtml(payload: ToastPayload): string { + return ` + + + + + + +
+
+
${escapeHtml(payload.title)}
+
${escapeHtml(payload.message)}
+
+ +
+ +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c]!)); +} +function escapeAttr(s: string): string { return escapeHtml(s); } + +export class MeetingToastWindow { + private win: BrowserWindow | null = null; + private timer: NodeJS.Timeout | null = null; + + show(payload: ToastPayload): void { + // If a previous toast is still up, replace it. + this.closeImmediate(); + + const display = screen.getPrimaryDisplay(); + const wa = display.workArea; + const x = Math.round(wa.x + (wa.width - TOAST_WIDTH) / 2); + const y = wa.y + TOAST_TOP_MARGIN; + + const win = new BrowserWindow({ + width: TOAST_WIDTH, + height: TOAST_HEIGHT, + x, y, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + focusable: false, + show: false, + hasShadow: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + // alwaysOnTop with screen-saver level so it floats above full-screen apps too. + win.setAlwaysOnTop(true, "screen-saver"); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + // Intercept the action links — both rowboat:// (real deeplinks) and + // rowboat-toast://dismiss (our internal dismiss signal). + win.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + if (url.startsWith("rowboat-toast://")) { + this.closeImmediate(); + return; + } + if (url.startsWith("rowboat://")) { + dispatchUrl(url); + this.closeImmediate(); + return; + } + // Anything else (shouldn't happen with our own HTML) — ignore. + }); + + win.once("ready-to-show", () => win.show()); + win.on("closed", () => { + if (this.win === win) this.win = null; + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + }); + + const html = buildToastHtml(payload); + // data: URL so we don't have to ship a separate file. Encoded so the + // protocol parser doesn't choke on quotes / hashes in the payload. + win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + + this.win = win; + this.timer = setTimeout(() => this.closeImmediate(), AUTO_DISMISS_MS); + } + + closeImmediate(): void { + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + if (this.win && !this.win.isDestroyed()) this.win.close(); + this.win = null; + } +} From 1fca31f1c727f9f243834baf4ace76a23552e1ce Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 16:19:09 +0530 Subject: [PATCH 5/6] feat: polish meeting toast design and add dev preview hotkey --- apps/x/apps/main/src/main.ts | 24 +++ .../main/src/meeting-detect/service.test.ts | 11 +- .../x/apps/main/src/meeting-detect/service.ts | 24 ++- .../src/meeting-detect/suppression.test.ts | 4 +- .../src/meeting-detect/toast-window.test.ts | 30 ++-- .../main/src/meeting-detect/toast-window.ts | 161 ++++++++++++------ 6 files changed, 177 insertions(+), 77 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c873a1f5..79a7aead 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -50,6 +50,7 @@ import { MeetingDetectService, Suppression, } from "./meeting-detect/index.js"; +import { MeetingToastWindow } from "./meeting-detect/toast-window.js"; import { DEEP_LINK_SCHEME, dispatchUrl, @@ -242,6 +243,29 @@ function createWindow() { setMainWindowForDeepLinks(win); win.on("closed", () => setMainWindowForDeepLinks(null)); + // Dev-only: Ctrl+Shift+T fires a fake meeting-detect toast so we can + // iterate on the toast UI without joining a real Meet. Scoped to the + // main window's input events so it can't collide with browser/OS chords. + if (!app.isPackaged) { + win.webContents.on("before-input-event", (event, input) => { + const isToggle = + input.type === "keyDown" && + input.control && input.shift && !input.alt && !input.meta && + input.key.toLowerCase() === "t"; + if (!isToggle) return; + event.preventDefault(); + new MeetingToastWindow().show({ + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", + actionLink: + "rowboat://action?type=take-meeting-notes&title=" + + encodeURIComponent("Meeting Notes - Meet"), + }); + console.log("[MeetingDetect] dev toast triggered (Ctrl+Shift+T)"); + }); + } + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 6b076889..476730fa 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -34,7 +34,7 @@ describe("buildPopup", () => { 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.title).toBe("You are in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". @@ -156,10 +156,10 @@ describe("MeetingDetectService end-to-end", () => { }); it("uses the toast renderer when provided instead of the native notifier", async () => { - const calls: Array<{ title: string; message: string; actionLink: string }> = []; + const calls: Array<{ title: string; subtitle: string; actionLink: string }> = []; const toast = { - show(p: { title: string; message: string; actionLabel: string; actionLink: string }) { - calls.push({ title: p.title, message: p.message, actionLink: p.actionLink }); + show(p: { title: string; subtitle: string; actionLabel: string; actionLink: string }) { + calls.push({ title: p.title, subtitle: p.subtitle, actionLink: p.actionLink }); }, }; const service = new MeetingDetectService({ @@ -178,7 +178,8 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(0); expect(calls).toHaveLength(1); - expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].title).toBe("You are in a meeting"); + expect(calls[0].subtitle).toContain("Zoom"); expect(calls[0].actionLink).toContain("take-meeting-notes"); }); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 37339825..0da141f5 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -126,8 +126,8 @@ export class MeetingDetectService { try { if (this.toast) { this.toast.show({ - title: payload.notify.title, - message: payload.notify.message, + title: payload.toast.title, + subtitle: payload.toast.subtitle, actionLabel: payload.notify.actionLabel, actionLink: payload.notify.link, }); @@ -149,6 +149,13 @@ interface BuiltPopup { link: string; actionLabel: string; }; + // Toast-specific fields (subtitle is the secondary line; the native + // notification API only has one body string, so we collapse title+subtitle + // into `message` when falling back to the OS notifier). + toast: { + title: string; + subtitle: string; + }; } export function buildPopup( @@ -161,13 +168,16 @@ export function buildPopup( if (!platformLabel) return null; if (correlated) { + const toastTitle = correlated.summary; + const toastSubtitle = `On ${platformLabel}`; 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", + actionLabel: "Start taking notes", }, + toast: { title: toastTitle, subtitle: toastSubtitle }, }; } @@ -177,10 +187,14 @@ export function buildPopup( const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; return { notify: { - title: "You're in a meeting", + title: "You are in a meeting", message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, - actionLabel: "Take notes", + actionLabel: "Start taking notes", + }, + toast: { + title: "You are in a meeting", + subtitle: `Detected on ${platformLabel}`, }, }; } diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts index ba569fdd..ab521f11 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.test.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -26,7 +26,9 @@ describe("Suppression", () => { }); it("respects the dismiss cooldown window", async () => { - const t0 = new Date("2026-05-15T10:00:00Z"); + // Anchor at "now" — gc() filters by wall-clock age, so a hard-coded + // past date would be dropped on persist and the cooldown wouldn't apply. + const t0 = new Date(); await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0); const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts index 3d4c7244..eed24d7f 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -2,31 +2,41 @@ import { describe, it, expect } from "vitest"; import { buildToastHtml } from "./toast-window.js"; describe("buildToastHtml", () => { - it("renders title, message, action label, and a primary link to the rowboat deeplink", () => { + it("renders title, subtitle, CTA and a link to the rowboat deeplink", () => { const html = buildToastHtml({ - title: "You're in a meeting", - message: "Detected on Google Meet. Click to take notes.", - actionLabel: "Take notes", + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", }); - expect(html).toContain("You're in a meeting"); + expect(html).toContain("You are in a meeting"); expect(html).toContain("Detected on Google Meet"); - expect(html).toContain("Take notes"); + expect(html).toContain("Start taking notes"); expect(html).toContain("rowboat://action?type=take-meeting-notes"); }); + it("includes the rowboat wordmark and accessibility attributes", () => { + const html = buildToastHtml({ + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain(">rowboat<"); + expect(html).toContain('role="alert"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('aria-label="Dismiss meeting notification"'); + }); + it("includes a dismiss link the window will intercept", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", }); expect(html).toContain("rowboat-toast://dismiss"); }); - it("escapes HTML in title/message so a Meet titled `", - message: "& < > \" '", + subtitle: "& < > \" '", actionLabel: "ok", actionLink: "rowboat://action", }); @@ -37,7 +47,7 @@ describe("buildToastHtml", () => { it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "ok", + title: "x", subtitle: "y", actionLabel: "ok", actionLink: `rowboat://action?title=evil"onerror=alert(1)`, }); expect(html).not.toContain(`"onerror=alert(1)`); diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.ts b/apps/x/apps/main/src/meeting-detect/toast-window.ts index 2b36252c..f66ae48b 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -1,24 +1,18 @@ import { BrowserWindow, screen } from "electron"; import { dispatchUrl } from "../deeplink.js"; -// Custom Notion-style meeting toast: top-center frameless window with our -// own React-less HTML. We avoid the OS notification API because Windows -// and macOS both force-position native notifications (bottom-right / top- -// right respectively) and we want top-center. +// Notion-style meeting toast: top-center frameless window with our own HTML. +// Persistent — closes only when the user clicks the CTA or the X. // -// Lifecycle: -// show() → opens window, slides in, auto-closes at AUTO_DISMISS_MS -// action button click → navigation to rowboat:// → dispatchUrl + close -// dismiss button click → navigation to rowboat://dismiss → just close +// Spec: white card, top: 24px, max-width 640, slide-down entry animation. -const TOAST_WIDTH = 460; -const TOAST_HEIGHT = 110; -const TOAST_TOP_MARGIN = 56; -const AUTO_DISMISS_MS = 30_000; +const TOAST_WIDTH = 560; +const TOAST_HEIGHT = 92; +const TOAST_TOP_MARGIN = 24; export interface ToastPayload { title: string; - message: string; + subtitle: string; actionLabel: string; actionLink: string; } @@ -31,50 +25,116 @@ export function buildToastHtml(payload: ToastPayload): string { -
-
+ `; @@ -89,7 +149,6 @@ function escapeAttr(s: string): string { return escapeHtml(s); } export class MeetingToastWindow { private win: BrowserWindow | null = null; - private timer: NodeJS.Timeout | null = null; show(payload: ToastPayload): void { // If a previous toast is still up, replace it. @@ -122,12 +181,9 @@ export class MeetingToastWindow { sandbox: true, }, }); - // alwaysOnTop with screen-saver level so it floats above full-screen apps too. win.setAlwaysOnTop(true, "screen-saver"); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - // Intercept the action links — both rowboat:// (real deeplinks) and - // rowboat-toast://dismiss (our internal dismiss signal). win.webContents.on("will-navigate", (event, url) => { event.preventDefault(); if (url.startsWith("rowboat-toast://")) { @@ -139,26 +195,19 @@ export class MeetingToastWindow { this.closeImmediate(); return; } - // Anything else (shouldn't happen with our own HTML) — ignore. }); win.once("ready-to-show", () => win.show()); - win.on("closed", () => { - if (this.win === win) this.win = null; - if (this.timer) { clearTimeout(this.timer); this.timer = null; } - }); + win.on("closed", () => { if (this.win === win) this.win = null; }); const html = buildToastHtml(payload); - // data: URL so we don't have to ship a separate file. Encoded so the - // protocol parser doesn't choke on quotes / hashes in the payload. win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); this.win = win; - this.timer = setTimeout(() => this.closeImmediate(), AUTO_DISMISS_MS); + // No auto-dismiss — persistent until X or CTA click (per spec). } closeImmediate(): void { - if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.win && !this.win.isDestroyed()) this.win.close(); this.win = null; } From 5d8eecd3dc1807a3c135c8ade08e730e5ba739fe Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 11:24:36 +0530 Subject: [PATCH 6/6] fix for mac --- .../src/meeting-detect/foreground-window.ts | 112 ++++++++++++++++-- .../src/meeting-detect/meeting-apps.test.ts | 25 ++++ .../main/src/meeting-detect/meeting-apps.ts | 6 +- 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index e9f523d0..4f4ac579 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -11,16 +11,19 @@ export interface WindowSnapshot { } /** - * Best-effort look at currently-open window titles for a given executable. - * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips - * every system process. On macOS: AppleScript for the frontmost window. + * Best-effort look at currently-open window titles (and, on macOS, tab URLs) + * for a given executable. On Windows: `tasklist /v /fi "imagename eq "` — + * fast because it skips every system process. On macOS: AppleScript that + * enumerates every browser tab (URL + title) for Chromium-family browsers and + * Safari, falling back to the frontmost window title for everything else. * - * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; - * an empty title list means "process is running but no window has a title." + * Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name. + * Returns null on failure; an empty title list means "process is running but no + * window/tab title is available." */ export async function getWindowSnapshot(executable?: string): Promise { if (process.platform === "win32") return getWindowSnapshotWindows(executable); - if (process.platform === "darwin") return getWindowSnapshotMacOS(); + if (process.platform === "darwin") return getWindowSnapshotMacOS(executable); return null; } @@ -63,10 +66,49 @@ function parseCsvLine(line: string): string[] { return out; } -// 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 = ` +// Chromium-family browsers share Chrome's AppleScript dictionary (each tab +// exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and +// anything else expose no tab scripting, so they fall back to the frontmost +// window title. Keyed by a substring of the pmset process name. +const CHROMIUM_APPS: Record = { + "google chrome": "Google Chrome", + "brave browser": "Brave Browser", + "microsoft edge": "Microsoft Edge", + "vivaldi": "Vivaldi", + "opera": "Opera", + "arc": "Arc", +}; + +function browserApp(executable?: string): { app: string; titleProp: "title" | "name" } | null { + const e = (executable ?? "").toLowerCase(); + for (const [needle, app] of Object.entries(CHROMIUM_APPS)) { + if (e.includes(needle)) return { app, titleProp: "title" }; + } + if (e.includes("safari")) return { app: "Safari", titleProp: "name" }; + return null; +} + +// Walk every window/tab of a browser and emit "\n" per tab. We need +// ALL tabs, not just the frontmost: the user is often looking at another app +// (e.g. taking notes) while the Meet/Zoom/Teams tab sits in the background. +function tabEnumScript(app: string, titleProp: "title" | "name"): string { + return [ + `tell application "${app}"`, + ` set _out to ""`, + ` repeat with _w in windows`, + ` repeat with _t in tabs of _w`, + ` set _out to _out & (URL of _t) & linefeed & (${titleProp} of _t) & linefeed`, + ` end repeat`, + ` end repeat`, + ` return _out`, + `end tell`, + ].join("\n"); +} + +// Frontmost window title — needs Accessibility permission. Last-resort signal +// for Firefox/unknown browsers (no tab scripting) or when tab enumeration is +// blocked. +const FRONT_WINDOW_SCRIPT = ` tell application "System Events" set frontApp to first application process whose frontmost is true set appName to name of frontApp @@ -79,16 +121,60 @@ tell application "System Events" end tell `.trim(); -async function getWindowSnapshotMacOS(): Promise<WindowSnapshot | null> { +function isPermissionError(err: unknown): boolean { + // osascript denied by TCC: Automation (-1743) or Accessibility (-1719). + const msg = err instanceof Error ? `${err.message} ${(err as { stderr?: string }).stderr ?? ""}` : String(err); + return msg.includes("-1743") || msg.includes("-1719") || /not authoriz|not allowed/i.test(msg); +} + +async function getWindowSnapshotMacOS(executable?: string): Promise<WindowSnapshot | null> { + const browser = browserApp(executable); + if (browser) { + const tabs = await enumerateBrowserTabs(browser.app, browser.titleProp); + if (tabs && tabs.length > 0) return { titles: tabs }; + // Empty/blocked → fall through to the frontmost-window title below. + } + return frontmostWindowTitle(); +} + +async function enumerateBrowserTabs(app: string, titleProp: "title" | "name"): Promise<string[] | null> { try { - const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", tabEnumScript(app, titleProp)], { + timeout: 5_000, + maxBuffer: 4 * 1024 * 1024, + }); + // Each tab contributed a URL line and a title line; both feed matchTitleOrUrl. + return stdout.split("\n").map((l) => l.trim()).filter(Boolean); + } catch (err) { + if (isPermissionError(err)) { + console.warn( + `[MeetingDetect] cannot read ${app} tabs — grant Automation permission in ` + + `System Settings → Privacy & Security → Automation (Rowboat → ${app}). Falling back to window title.`, + ); + } else { + console.error(`[MeetingDetect] tab enumeration (${app}) failed:`, err); + } + return null; + } +} + +async function frontmostWindowTitle(): Promise<WindowSnapshot | null> { + try { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", FRONT_WINDOW_SCRIPT], { timeout: 5_000, }); const [, ...titleParts] = stdout.trim().split("\n"); const title = titleParts.join("\n"); return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + if (isPermissionError(err)) { + console.warn( + "[MeetingDetect] cannot read the frontmost window title — grant Accessibility " + + "permission in System Settings → Privacy & Security → Accessibility (Rowboat).", + ); + } else { + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + } return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts new file mode 100644 index 00000000..c0072509 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { classifyExecutable } from "./meeting-apps.js"; + +describe("classifyExecutable", () => { + it("classifies Zoom on both platforms", () => { + expect(classifyExecutable("Zoom.exe")).toBe("zoom"); // Windows + expect(classifyExecutable("zoom.us")).toBe("zoom"); // macOS pmset name + }); + + it("classifies the new Teams client by its macOS/Windows process name", () => { + expect(classifyExecutable("MSTeams")).toBe("teams"); // macOS pmset name + expect(classifyExecutable("ms-teams.exe")).toBe("teams"); // Windows + expect(classifyExecutable("Microsoft Teams")).toBe("teams"); // classic + }); + + it("classifies browsers as the browser kind", () => { + expect(classifyExecutable("Google Chrome")).toBe("browser"); + expect(classifyExecutable("Safari")).toBe("browser"); + }); + + it("returns unknown for unrelated processes", () => { + expect(classifyExecutable("Finder")).toBe("unknown"); + expect(classifyExecutable("WindowServer")).toBe("unknown"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts index a18dec0f..fc1b35b4 100644 --- a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts @@ -7,13 +7,15 @@ export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | interface AppRule { kind: MeetingAppKind; // Case-insensitive substring match against the executable path / basename - // (Windows: full exe path from registry; macOS: command name from lsof). + // (Windows: full exe path from registry; macOS: process name from pmset). match: string[]; } const RULES: AppRule[] = [ { kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] }, - { kind: "teams", match: ["ms-teams.exe", "teams.exe", "microsoft teams"] }, + // "msteams" covers the current macOS/Windows process name (the new Teams ships + // as MSTeams); the others cover the classic client and the AUMID/bundle forms. + { kind: "teams", match: ["ms-teams.exe", "teams.exe", "msteams", "microsoft teams"] }, { kind: "slack", match: ["slack.exe", "slack helper", "slack"] }, { kind: "discord", match: ["discord.exe", "discord"] }, { kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },