From 6c9d9206c88be14edb268420809adf2604bb2fb7 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 14:06:51 +0530 Subject: [PATCH] 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