Compare commits

...

6 commits
main ... v0.4.9

29 changed files with 2806 additions and 45 deletions

View file

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

View file

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

View file

@ -45,6 +45,12 @@ 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 { MeetingToastWindow } from "./meeting-detect/toast-window.js";
import {
DEEP_LINK_SCHEME,
dispatchUrl,
@ -237,6 +243,29 @@ function createWindow() {
setMainWindowForDeepLinks(win);
win.on("closed", () => setMainWindowForDeepLinks(null));
// Dev-only: Ctrl+Shift+T fires a fake meeting-detect toast so we can
// iterate on the toast UI without joining a real Meet. Scoped to the
// main window's input events so it can't collide with browser/OS chords.
if (!app.isPackaged) {
win.webContents.on("before-input-event", (event, input) => {
const isToggle =
input.type === "keyDown" &&
input.control && input.shift && !input.alt && !input.meta &&
input.key.toLowerCase() === "t";
if (!isToggle) return;
event.preventDefault();
new MeetingToastWindow().show({
title: "You are in a meeting",
subtitle: "Detected on Google Meet",
actionLabel: "Start taking notes",
actionLink:
"rowboat://action?type=take-meeting-notes&title=" +
encodeURIComponent("Meeting Notes - Meet"),
});
console.log("[MeetingDetect] dev toast triggered (Ctrl+Shift+T)");
});
}
// Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => {
win.maximize();
@ -312,7 +341,8 @@ app.whenReady().then(async () => {
});
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
const notificationService = new ElectronNotificationService();
registerNotificationService(notificationService);
setupIpcHandlers();
setupBrowserEventForwarding();
@ -384,6 +414,30 @@ 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)
//
// Popup style — flip this one constant to switch the meeting-detect prompt
// between the custom Notion-style top-center toast and the native OS
// notification. Doesn't affect the separate calendar 1-min warnings.
// false (default) → custom toast
// true → native OS notification
const USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT = false;
const meetingDetector = createPlatformDetector();
if (meetingDetector) {
const meetingDetectService = new MeetingDetectService({
detector: meetingDetector,
notifier: notificationService,
suppression: new Suppression(),
toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined,
});
meetingDetectService.start().catch((err) => {
console.error("[MeetingDetect] failed to start:", err);
});
} else {
console.log("[MeetingDetect] no detector for this platform; skipping");
}
// start chrome extension sync server
initChromeSync();

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js";
let tmpRoot: string;
const NOW = new Date(2026, 4, 15, 14, 0, 0); // 2026-05-15 14:00 local
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rb-adhoc-title-"));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
async function writeNote(day: string, filename: string): Promise<void> {
const dir = path.join(tmpRoot, day);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, filename), "stub", "utf-8");
}
describe("buildAdHocTitle", () => {
it("returns the bare title for the first occurrence of the day", async () => {
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom");
});
it("appends #2 when one already exists", async () => {
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom #2");
});
it("increments past #2 (#3, #4, ...)", async () => {
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#2.md");
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#3.md");
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom #4");
});
it("doesn't cross-count platforms (Meet vs Zoom stay distinct)", async () => {
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
const title = await buildAdHocTitle({ platformLabel: "Meet", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Meet");
});
it("resets the counter on a different day", async () => {
await writeNote("2026-05-14", "Meeting_Notes_-_Zoom.md");
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom");
});
it("ignores non-meeting notes in the same folder", async () => {
await writeNote("2026-05-15", "standup.md");
await writeNote("2026-05-15", "random_note.md");
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom");
});
it("matches slug-variant filenames (different separators)", async () => {
// Whatever the renderer's slugifier does, normalize() should match.
await writeNote("2026-05-15", "Meeting Notes - Zoom.md");
await writeNote("2026-05-15", "Meeting-Notes--Zoom.md"); // hypothetical alt slug
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
expect(title).toBe("Meeting Notes - Zoom #3");
});
});
describe("shortPlatformLabel", () => {
it("maps browser platforms to short labels", () => {
expect(shortPlatformLabel({ browserPlatform: "google-meet", kind: "browser" })).toBe("Meet");
expect(shortPlatformLabel({ browserPlatform: "zoom-web", kind: "browser" })).toBe("Zoom");
expect(shortPlatformLabel({ browserPlatform: "teams-web", kind: "browser" })).toBe("Teams");
});
it("maps native kinds to short labels", () => {
expect(shortPlatformLabel({ kind: "zoom" })).toBe("Zoom");
expect(shortPlatformLabel({ kind: "teams" })).toBe("Teams");
expect(shortPlatformLabel({ kind: "discord" })).toBe("Discord");
});
it("returns null for unmatched browser / unknown", () => {
expect(shortPlatformLabel({ kind: "browser" })).toBeNull();
expect(shortPlatformLabel({ kind: "unknown" })).toBeNull();
});
});

View file

@ -0,0 +1,101 @@
import path from "node:path";
import fs from "node:fs/promises";
import { WorkDir } from "@x/core/dist/config/config.js";
// Ad-hoc meeting titles: "Meeting Notes - <Platform>" with a per-day counter
// suffix when there's already one for the same platform on the same day.
//
// first Zoom today → "Meeting Notes - Zoom"
// second Zoom today → "Meeting Notes - Zoom #2"
// first Zoom tomorrow → "Meeting Notes - Zoom" (fresh folder, fresh count)
const MEETINGS_ROOT = path.join(WorkDir, "knowledge", "Meetings", "rowboat");
const TITLE_PREFIX = "Meeting Notes - ";
export interface AdHocTitleOptions {
platformLabel: string;
now?: Date;
// Override for tests; defaults to the user's real meetings folder.
root?: string;
}
export async function buildAdHocTitle(opts: AdHocTitleOptions): Promise<string> {
const platform = opts.platformLabel;
const base = `${TITLE_PREFIX}${platform}`;
const now = opts.now ?? new Date();
const dayFolder = path.join(opts.root ?? MEETINGS_ROOT, formatDay(now));
const existing = await countMatching(dayFolder, base);
if (existing === 0) return base;
return `${base} #${existing + 1}`;
}
function formatDay(d: Date): string {
// YYYY-MM-DD in local time — matches the existing knowledge/Meetings/rowboat layout.
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
async function countMatching(dir: string, baseTitle: string): Promise<number> {
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
return 0;
}
const needle = normalize(baseTitle);
let count = 0;
for (const name of entries) {
if (!name.endsWith(".md")) continue;
const stem = name.slice(0, -3); // strip .md
if (normalize(stem).startsWith(needle)) count++;
}
return count;
}
/**
* Normalize a title or filename to alphanumerics-only-lowercase so we can
* compare across slugification rules:
* "Meeting Notes - Zoom" "meetingnoteszoom"
* "Meeting_Notes_-_Zoom.md" "meetingnoteszoom" (after .md strip)
* "Meeting Notes - Zoom #2" "meetingnoteszoom2"
*
* Anchoring with startsWith() then catches both the bare title and any
* counter-suffixed variant, without colliding across platforms ("Meet"
* vs "Zoom" stay distinct because the platform name appears after the
* common "meetingnotes" prefix).
*/
function normalize(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
}
// Map our internal platform/kind names to user-facing short labels.
// Re-exported so service.ts can produce both the popup body label and the
// note title from the same source of truth.
export function shortPlatformLabel(input: {
browserPlatform?: "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web";
kind: "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown";
}): string | null {
if (input.browserPlatform) {
switch (input.browserPlatform) {
case "google-meet": return "Meet";
case "zoom-web": return "Zoom";
case "teams-web": return "Teams";
case "slack-huddle": return "Slack";
case "webex-web": return "Webex";
}
}
switch (input.kind) {
case "zoom": return "Zoom";
case "teams": return "Teams";
case "slack": return "Slack";
case "discord": return "Discord";
case "webex": return "Webex";
case "browser":
case "unknown":
return null;
}
}

View file

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

View file

@ -0,0 +1,66 @@
import { getWindowSnapshot } from "./foreground-window.js";
export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web";
export interface BrowserMeetingMatch {
platform: BrowserMeetingPlatform;
// Best-effort URL or tab title we matched on — useful for the popup copy.
hint: string;
}
interface TitleRule {
platform: BrowserMeetingPlatform;
// Substrings checked against the (case-insensitive) window title / URL.
needles: string[];
}
// Substrings we look for in the foreground window title (or URL when we
// have it). On Chrome/Edge/Firefox the page title is embedded in the window
// title, which is the most reliable cross-platform signal.
// Meet page title: "Meet - Daily Standup" → matches "meet -"
// Zoom web client: "Zoom Meeting" → matches "zoom meeting"
// Teams web: "<topic> | Microsoft Teams" → matches "microsoft teams"
const RULES: TitleRule[] = [
{ platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] },
{ platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] },
{ platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] },
{ platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] },
{ platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] },
];
/**
* Look at the foreground window. If it's a browser and the title matches a
* known meeting URL/platform, return a match. Returns null otherwise.
*
* Caller is expected to only invoke this when the detector classified the
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap we
* only ask the OS when there's a reason to ask.
*/
export async function matchBrowserMeeting(executable?: string): Promise<BrowserMeetingMatch | null> {
const snap = await getWindowSnapshot(executable);
if (!snap) return null;
// Scan ALL known window titles — on Windows tasklist returns every window,
// so even a backgrounded Meet tab still matches while Chrome holds the mic.
for (const title of snap.titles) {
const m = matchTitleOrUrl(title, undefined);
if (m) return m;
}
return null;
}
/** Pure matcher — exposed for tests; no OS calls. */
export function matchTitleOrUrl(title: string | undefined, url: string | undefined): BrowserMeetingMatch | null {
// active-win returns `url` on macOS for Chromium-family + Safari (Accessibility-perm gated).
// On Windows, only `title` is reliable. Match against both.
const haystack = `${url ?? ""}\n${title ?? ""}`.toLowerCase();
if (!haystack.trim()) return null;
for (const rule of RULES) {
for (const needle of rule.needles) {
if (haystack.includes(needle)) {
return { platform: rule.platform, hint: url || title || "" };
}
}
}
return null;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,180 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface WindowSnapshot {
// Window titles we know about. Implementations may return one (foreground)
// or many (all titles for a process). browser-match scans the whole list,
// so we don't need to identify which is foreground.
titles: string[];
}
/**
* Best-effort look at currently-open window titles (and, on macOS, tab URLs)
* for a given executable. On Windows: `tasklist /v /fi "imagename eq <exe>"`
* fast because it skips every system process. On macOS: AppleScript that
* enumerates every browser tab (URL + title) for Chromium-family browsers and
* Safari, falling back to the frontmost window title for everything else.
*
* Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name.
* Returns null on failure; an empty title list means "process is running but no
* window/tab title is available."
*/
export async function getWindowSnapshot(executable?: string): Promise<WindowSnapshot | null> {
if (process.platform === "win32") return getWindowSnapshotWindows(executable);
if (process.platform === "darwin") return getWindowSnapshotMacOS(executable);
return null;
}
async function getWindowSnapshotWindows(executable?: string): Promise<WindowSnapshot | null> {
// Reduce to a basename — full paths can't be passed to tasklist's
// imagename filter, and the filter wants e.g. "chrome.exe", not the path.
const imageName = executable ? executable.replace(/^.*[\\/]/, "") : "";
const args = ["/v", "/fo", "csv", "/nh"];
if (imageName) args.push("/fi", `imagename eq ${imageName}`);
try {
const { stdout } = await execFileAsync(
"tasklist.exe",
args,
{ timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
);
const titles: string[] = [];
for (const line of stdout.split(/\r?\n/)) {
if (!line) continue;
const fields = parseCsvLine(line);
if (fields.length === 0) continue;
const title = fields[fields.length - 1];
if (!title || title === "N/A") continue;
titles.push(title);
}
return { titles };
} catch (err) {
console.error("[MeetingDetect] window-snapshot (windows) failed:", err);
return null;
}
}
function parseCsvLine(line: string): string[] {
// tasklist /fo csv quotes every field and doesn't embed quotes within fields,
// so a simple comma-split between quoted segments works.
const out: string[] = [];
const re = /"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) out.push(m[1]);
return out;
}
// Chromium-family browsers share Chrome's AppleScript dictionary (each tab
// exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and
// anything else expose no tab scripting, so they fall back to the frontmost
// window title. Keyed by a substring of the pmset process name.
const CHROMIUM_APPS: Record<string, string> = {
"google chrome": "Google Chrome",
"brave browser": "Brave Browser",
"microsoft edge": "Microsoft Edge",
"vivaldi": "Vivaldi",
"opera": "Opera",
"arc": "Arc",
};
function browserApp(executable?: string): { app: string; titleProp: "title" | "name" } | null {
const e = (executable ?? "").toLowerCase();
for (const [needle, app] of Object.entries(CHROMIUM_APPS)) {
if (e.includes(needle)) return { app, titleProp: "title" };
}
if (e.includes("safari")) return { app: "Safari", titleProp: "name" };
return null;
}
// Walk every window/tab of a browser and emit "<url>\n<title>" per tab. We need
// ALL tabs, not just the frontmost: the user is often looking at another app
// (e.g. taking notes) while the Meet/Zoom/Teams tab sits in the background.
function tabEnumScript(app: string, titleProp: "title" | "name"): string {
return [
`tell application "${app}"`,
` set _out to ""`,
` repeat with _w in windows`,
` repeat with _t in tabs of _w`,
` set _out to _out & (URL of _t) & linefeed & (${titleProp} of _t) & linefeed`,
` end repeat`,
` end repeat`,
` return _out`,
`end tell`,
].join("\n");
}
// Frontmost window title — needs Accessibility permission. Last-resort signal
// for Firefox/unknown browsers (no tab scripting) or when tab enumeration is
// blocked.
const FRONT_WINDOW_SCRIPT = `
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set winTitle to name of front window of frontApp
on error
set winTitle to ""
end try
return appName & "\\n" & winTitle
end tell
`.trim();
function isPermissionError(err: unknown): boolean {
// osascript denied by TCC: Automation (-1743) or Accessibility (-1719).
const msg = err instanceof Error ? `${err.message} ${(err as { stderr?: string }).stderr ?? ""}` : String(err);
return msg.includes("-1743") || msg.includes("-1719") || /not authoriz|not allowed/i.test(msg);
}
async function getWindowSnapshotMacOS(executable?: string): Promise<WindowSnapshot | null> {
const browser = browserApp(executable);
if (browser) {
const tabs = await enumerateBrowserTabs(browser.app, browser.titleProp);
if (tabs && tabs.length > 0) return { titles: tabs };
// Empty/blocked → fall through to the frontmost-window title below.
}
return frontmostWindowTitle();
}
async function enumerateBrowserTabs(app: string, titleProp: "title" | "name"): Promise<string[] | null> {
try {
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", tabEnumScript(app, titleProp)], {
timeout: 5_000,
maxBuffer: 4 * 1024 * 1024,
});
// Each tab contributed a URL line and a title line; both feed matchTitleOrUrl.
return stdout.split("\n").map((l) => l.trim()).filter(Boolean);
} catch (err) {
if (isPermissionError(err)) {
console.warn(
`[MeetingDetect] cannot read ${app} tabs — grant Automation permission in ` +
`System Settings → Privacy & Security → Automation (Rowboat → ${app}). Falling back to window title.`,
);
} else {
console.error(`[MeetingDetect] tab enumeration (${app}) failed:`, err);
}
return null;
}
}
async function frontmostWindowTitle(): Promise<WindowSnapshot | null> {
try {
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", FRONT_WINDOW_SCRIPT], {
timeout: 5_000,
});
const [, ...titleParts] = stdout.trim().split("\n");
const title = titleParts.join("\n");
return { titles: title ? [title] : [] };
} catch (err) {
if (isPermissionError(err)) {
console.warn(
"[MeetingDetect] cannot read the frontmost window title — grant Accessibility " +
"permission in System Settings → Privacy & Security → Accessibility (Rowboat).",
);
} else {
console.error("[MeetingDetect] window-snapshot (macOS) failed:", err);
}
return null;
}
}

View file

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

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { classifyExecutable } from "./meeting-apps.js";
describe("classifyExecutable", () => {
it("classifies Zoom on both platforms", () => {
expect(classifyExecutable("Zoom.exe")).toBe("zoom"); // Windows
expect(classifyExecutable("zoom.us")).toBe("zoom"); // macOS pmset name
});
it("classifies the new Teams client by its macOS/Windows process name", () => {
expect(classifyExecutable("MSTeams")).toBe("teams"); // macOS pmset name
expect(classifyExecutable("ms-teams.exe")).toBe("teams"); // Windows
expect(classifyExecutable("Microsoft Teams")).toBe("teams"); // classic
});
it("classifies browsers as the browser kind", () => {
expect(classifyExecutable("Google Chrome")).toBe("browser");
expect(classifyExecutable("Safari")).toBe("browser");
});
it("returns unknown for unrelated processes", () => {
expect(classifyExecutable("Finder")).toBe("unknown");
expect(classifyExecutable("WindowServer")).toBe("unknown");
});
});

View file

@ -0,0 +1,51 @@
// 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: process name from pmset).
match: string[];
}
const RULES: AppRule[] = [
{ kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] },
// "msteams" covers the current macOS/Windows process name (the new Teams ships
// as MSTeams); the others cover the classic client and the AUMID/bundle forms.
{ kind: "teams", match: ["ms-teams.exe", "teams.exe", "msteams", "microsoft teams"] },
{ kind: "slack", match: ["slack.exe", "slack helper", "slack"] },
{ kind: "discord", match: ["discord.exe", "discord"] },
{ kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },
// Browsers — kind "browser" means we still need a tab-title check before firing.
{ kind: "browser", match: [
"chrome.exe", "google chrome",
"msedge.exe", "microsoft edge",
"firefox.exe", "firefox",
"arc.exe", "arc",
"brave.exe", "brave browser",
"safari",
"vivaldi.exe", "vivaldi",
"opera.exe", "opera",
]},
];
export function classifyExecutable(executable: string): MeetingAppKind {
const haystack = executable.toLowerCase();
for (const rule of RULES) {
for (const needle of rule.match) {
if (haystack.includes(needle)) return rule.kind;
}
}
return "unknown";
}
export function isMeetingApp(executable: string): boolean {
return classifyExecutable(executable) !== "unknown";
}
export function isBrowser(executable: string): boolean {
return classifyExecutable(executable) === "browser";
}

View file

@ -0,0 +1,47 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { MicProbe, MicUser } from "./types.js";
const execFileAsync = promisify(execFile);
// macOS doesn't expose a public "who is using the mic right now" API. Two
// pragmatic signals we can read from a shell without a native helper:
//
// 1. `pmset -g assertions` — apps in a video call almost always hold a
// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong
// proxy for "active call." False positives: video playback (YouTube,
// Netflix) — Phase 2's tab-title check filters those out for browsers.
//
// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and
// doesn't always include the mic user, so we prefer pmset as primary.
//
// Output format from `pmset -g assertions`:
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
export class MacOsMicProbe implements MicProbe {
async probe(): Promise<MicUser[]> {
let stdout: string;
try {
const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], {
timeout: 10_000,
});
stdout = result.stdout;
} catch (err) {
console.error("[MeetingDetect] macOS probe failed:", err);
return [];
}
const seen = new Map<number, MicUser>();
for (const line of stdout.split("\n")) {
const m = ASSERTION_LINE.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const command = m[2].trim();
if (!Number.isFinite(pid)) continue;
if (seen.has(pid)) continue;
seen.set(pid, { executable: command, pid });
}
return Array.from(seen.values());
}
}

View file

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

View file

@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
import { MeetingDetector } from "./detector.js";
import type { MicProbe, MicUser } from "./types.js";
import { MeetingDetectService, buildPopup } from "./service.js";
import { Suppression, InMemorySuppressionStore } from "./suppression.js";
import type { BrowserMeetingMatch } from "./browser-match.js";
import type { CorrelatedEvent } from "./calendar-correlate.js";
class FakeProbe implements MicProbe {
next: MicUser[] = [];
async probe(): Promise<MicUser[]> { return this.next; }
}
class FakeNotifier implements INotificationService {
sent: NotifyInput[] = [];
isSupported(): boolean { return true; }
notify(input: NotifyInput): void { this.sent.push(input); }
}
describe("buildPopup", () => {
it("uses the calendar event summary when correlated", () => {
const corr: CorrelatedEvent = {
eventId: "abc123",
summary: "Q2 Planning",
startMs: 0, endMs: 0,
attendees: [],
};
const popup = buildPopup("zoom", null, corr);
expect(popup?.notify.message).toContain("Q2 Planning");
expect(popup?.notify.link).toContain("eventId=abc123");
expect(popup?.notify.link).toContain("take-meeting-notes");
});
it("falls back to ad-hoc copy when no calendar match", () => {
const popup = buildPopup("zoom", null, null);
expect(popup?.notify.title).toBe("You are in a meeting");
expect(popup?.notify.link).toContain("title=");
expect(popup?.notify.link).not.toContain("eventId=");
// Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom".
expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom");
});
it("uses the precomputed ad-hoc title when provided (counter case)", () => {
const popup = buildPopup("zoom", null, null, "Meeting Notes - Zoom #2");
expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom #2");
});
it("uses browser match platform label when kind=browser", () => {
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,
toast: null,
});
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,
toast: 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,
toast: 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,
toast: 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("uses the toast renderer when provided instead of the native notifier", async () => {
const calls: Array<{ title: string; subtitle: string; actionLink: string }> = [];
const toast = {
show(p: { title: string; subtitle: string; actionLabel: string; actionLink: string }) {
calls.push({ title: p.title, subtitle: p.subtitle, actionLink: p.actionLink });
},
};
const service = new MeetingDetectService({
detector,
notifier,
suppression,
matchBrowser: async () => null,
correlate: async () => null,
toast,
});
await service.start();
probe.next = [{ executable: "zoom.us", pid: 100 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(0);
expect(calls).toHaveLength(1);
expect(calls[0].title).toBe("You are in a meeting");
expect(calls[0].subtitle).toContain("Zoom");
expect(calls[0].actionLink).toContain("take-meeting-notes");
});
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,
toast: null,
});
await service.start();
probe.next = [{ executable: "Discord", pid: 300 }];
await detector.tick();
await service.settle();
expect(notifier.sent).toHaveLength(0);
});
});

View file

@ -0,0 +1,221 @@
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";
import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js";
import { MeetingToastWindow, type ToastPayload } from "./toast-window.js";
// Glue layer: turns detector events into popup notifications, gated by browser
// tab matching, calendar correlation, and the suppression store.
//
// Tests inject their own detector + notification service + suppression so this
// runs without touching the OS.
type Matcher = (executable?: string) => Promise<BrowserMeetingMatch | null>;
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
export interface MeetingDetectServiceOptions {
detector: MeetingDetector;
notifier: INotificationService;
suppression: Suppression;
// Defaults run the real OS-touching versions; tests override.
matchBrowser?: Matcher;
correlate?: Correlator;
// Custom popup renderer. When provided (default in production), the toast
// is used instead of the native OS notification. Tests pass null to fall
// back to the notifier and assert on its calls.
toast?: { show(payload: ToastPayload): void } | null;
}
export class MeetingDetectService {
private readonly detector: MeetingDetector;
private readonly notifier: INotificationService;
private readonly suppression: Suppression;
private readonly matchBrowser: Matcher;
private readonly correlate: Correlator;
private readonly toast: { show(payload: ToastPayload): void } | null;
// Track async work spawned from detector events so tests (and shutdown)
// can wait for it to settle.
private pending = new Set<Promise<void>>();
constructor(opts: MeetingDetectServiceOptions) {
this.detector = opts.detector;
this.notifier = opts.notifier;
this.suppression = opts.suppression;
this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting;
this.correlate = opts.correlate ?? ((now) => correlateNow(now));
// `toast` is explicitly nullable so tests can opt out. Undefined →
// build the real one. Null → use the native notifier instead.
this.toast = opts.toast === undefined ? new MeetingToastWindow() : opts.toast;
}
async start(): Promise<void> {
await this.suppression.init();
if (!this.notifier.isSupported()) {
console.warn("[MeetingDetect] notification service unsupported; detector will run but no popups will fire");
}
this.detector.on("meeting-active", (event) => {
const work = this.handleActive(event).catch((err) => {
console.error("[MeetingDetect] handleActive failed:", err);
});
this.pending.add(work);
void work.finally(() => this.pending.delete(work));
});
this.detector.on("meeting-cleared", (event) => {
// Mic released → drop the session's suppression so the next call
// (same Chrome process, new Meet) can fire again.
this.suppression.clearSession(event.sessionKey).catch((err) => {
console.error("[MeetingDetect] clearSession failed:", err);
});
console.log(`[MeetingDetect] session cleared: ${event.sessionKey}`);
});
this.detector.start();
console.log("[MeetingDetect] service started — polling for meeting apps holding the mic");
}
stop(): void {
this.detector.stop();
}
/** Test hook — resolves once all in-flight handleActive() calls complete. */
async settle(): Promise<void> {
while (this.pending.size > 0) {
await Promise.all([...this.pending]);
}
}
private async handleActive(event: MeetingActiveEvent): Promise<void> {
console.log(`[MeetingDetect] active: ${event.executable} (kind=${event.kind})`);
if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) {
console.log(`[MeetingDetect] suppressed (already notified or muted): ${event.sessionKey}`);
return;
}
// For browsers we MUST confirm the foreground tab is a meeting page —
// otherwise we'd popup for YouTube, Spotify web, etc.
let browserMatch: BrowserMeetingMatch | null = null;
if (event.kind === "browser") {
browserMatch = await this.matchBrowser(event.executable);
if (!browserMatch) return;
}
const correlated = await this.correlate(new Date()).catch(() => null);
// Ad-hoc only: compute "Meeting Notes - <Platform> [#N]" so the note
// file lands with a useful title. Skip when we have a real calendar
// event — that already provides the right summary.
let adHocTitle: string | undefined;
if (!correlated) {
const short = shortPlatformLabel({
browserPlatform: browserMatch?.platform,
kind: event.kind,
});
if (short) {
adHocTitle = await buildAdHocTitle({ platformLabel: short }).catch((err) => {
console.error("[MeetingDetect] buildAdHocTitle failed:", err);
return `Meeting Notes - ${short}`;
});
}
}
const payload = buildPopup(event.kind, browserMatch, correlated, adHocTitle);
if (!payload) return;
try {
if (this.toast) {
this.toast.show({
title: payload.toast.title,
subtitle: payload.toast.subtitle,
actionLabel: payload.notify.actionLabel,
actionLink: payload.notify.link,
});
} else {
this.notifier.notify(payload.notify);
}
await this.suppression.markNotified(event.sessionKey);
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
} catch (err) {
console.error("[MeetingDetect] popup failed:", err);
}
}
}
interface BuiltPopup {
notify: {
title: string;
message: string;
link: string;
actionLabel: string;
};
// Toast-specific fields (subtitle is the secondary line; the native
// notification API only has one body string, so we collapse title+subtitle
// into `message` when falling back to the OS notifier).
toast: {
title: string;
subtitle: string;
};
}
export function buildPopup(
kind: MeetingAppKind,
browserMatch: BrowserMeetingMatch | null,
correlated: CorrelatedEvent | null,
adHocTitle?: string,
): BuiltPopup | null {
const platformLabel = describePlatform(kind, browserMatch);
if (!platformLabel) return null;
if (correlated) {
const toastTitle = correlated.summary;
const toastSubtitle = `On ${platformLabel}`;
return {
notify: {
title: "Take notes for this meeting?",
message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`,
link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`,
actionLabel: "Start taking notes",
},
toast: { title: toastTitle, subtitle: toastSubtitle },
};
}
// Ad-hoc — no calendar event matched. Use the precomputed counter-aware
// title ("Meeting Notes - Zoom" / "... #2") if available; fall back to a
// simple platform-suffixed title.
const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`;
return {
notify: {
title: "You are in a meeting",
message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`,
link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`,
actionLabel: "Start taking notes",
},
toast: {
title: "You are in a meeting",
subtitle: `Detected on ${platformLabel}`,
},
};
}
function describePlatform(kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null): string | null {
if (browserMatch) {
switch (browserMatch.platform) {
case "google-meet": return "Google Meet";
case "zoom-web": return "Zoom";
case "teams-web": return "Microsoft Teams";
case "slack-huddle": return "Slack huddle";
case "webex-web": return "Webex";
}
}
switch (kind) {
case "zoom": return "Zoom";
case "teams": return "Microsoft Teams";
case "slack": return "Slack";
case "discord": return "Discord";
case "webex": return "Webex";
case "browser": return null; // shouldn't happen — caller bails before us when no browserMatch
case "unknown": return null;
}
}

View file

@ -0,0 +1,78 @@
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 () => {
// Anchor at "now" — gc() filters by wall-clock age, so a hard-coded
// past date would be dropped on persist and the cooldown wouldn't apply.
const t0 = new Date();
await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0);
const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later
expect(suppression.shouldNotify("zoom.us#200", "zoom.us", within)).toBe(false);
const after = new Date(t0.getTime() + 31 * 60 * 1000); // 31 min later — past 30-min cooldown
// Cooldown GC drops entries past the window — re-load to apply GC.
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("zoom.us#200", "zoom.us", after)).toBe(true);
});
it("permanently mutes an app", async () => {
await suppression.muteApp("/Applications/Discord.app/Contents/MacOS/Discord");
expect(suppression.shouldNotify("Discord#9", "Discord")).toBe(false);
// And after reload, still muted.
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("Discord#10", "Discord")).toBe(false);
});
it("persists state through save/load", async () => {
await suppression.markNotified("zoom.us#100");
await suppression.muteApp("Discord");
const snap = store.snapshot();
expect(snap.notifiedSessions["zoom.us#100"]).toBeDefined();
expect(snap.mutedApps).toContain("discord");
const reloaded = new Suppression(store);
await reloaded.init();
expect(reloaded.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
expect(reloaded.isMuted("Discord")).toBe(true);
});
it("dismiss key normalizes path differences (Win path vs basename)", async () => {
const winPath = "C:\\Program Files\\Zoom\\bin\\Zoom.exe";
const macPath = "/Applications/Zoom.app/Contents/MacOS/zoom.us";
// Mute via mac-style path, expect it to apply when the detector reports the Windows-style path
// only if the basename matches. zoom.exe vs zoom.us differ, so they should NOT cross-match
// — verifying the dismiss key is the bare exe name and we don't over-match.
await suppression.muteApp(winPath);
expect(suppression.isMuted(winPath)).toBe(true);
expect(suppression.isMuted(macPath)).toBe(false);
});
});

View file

@ -0,0 +1,163 @@
import path from "node:path";
import fs from "node:fs/promises";
import { WorkDir } from "@x/core/dist/config/config.js";
const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json");
// Don't re-popup for the same exe within this window if the user dismissed.
const DISMISS_COOLDOWN_MS = 30 * 60 * 1000;
// Drop session-key entries older than 24h.
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
interface SuppressionState {
// Mic sessions we've already shown a popup for — keyed by detector sessionKey.
notifiedSessions: Record<string, { notifiedAt: string }>;
// User explicitly dismissed for this exe at this time.
recentlyDismissed: Record<string, { dismissedAt: string }>;
// Permanent "never offer for this app" list — exe substring matches.
mutedApps: string[];
}
function empty(): SuppressionState {
return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] };
}
export interface SuppressionStore {
load(): Promise<SuppressionState>;
save(state: SuppressionState): Promise<void>;
}
class FileSuppressionStore implements SuppressionStore {
private readonly file: string;
constructor(file: string) { this.file = file; }
async load(): Promise<SuppressionState> {
try {
const raw = await fs.readFile(this.file, "utf-8");
const parsed = JSON.parse(raw);
return normalize(parsed);
} catch {
return empty();
}
}
async save(state: SuppressionState): Promise<void> {
const tmp = `${this.file}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, this.file);
}
}
function normalize(raw: unknown): SuppressionState {
if (!raw || typeof raw !== "object") return empty();
const obj = raw as Partial<SuppressionState>;
return {
notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {},
recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {},
mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [],
};
}
export class Suppression {
private readonly store: SuppressionStore;
private state: SuppressionState = empty();
private loaded = false;
constructor(store?: SuppressionStore) {
this.store = store ?? new FileSuppressionStore(STATE_FILE);
}
async init(): Promise<void> {
this.state = gc(await this.store.load());
this.loaded = true;
}
/** Should we fire a popup for this (sessionKey, executable)? */
shouldNotify(sessionKey: string, executable: string, now: Date = new Date()): boolean {
if (!this.loaded) return true; // fail open — better to occasionally re-popup than to silently miss.
if (this.isMuted(executable)) return false;
if (this.state.notifiedSessions[sessionKey]) return false;
const dismissKey = dismissKeyFor(executable);
const recent = this.state.recentlyDismissed[dismissKey];
if (recent) {
const dismissedAt = Date.parse(recent.dismissedAt);
if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) {
return false;
}
}
return true;
}
async markNotified(sessionKey: string, now: Date = new Date()): Promise<void> {
this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() };
await this.persist();
}
/**
* Clear the notified mark for a session. Called when the detector observes
* the mic being released without this, on Windows (no pid in sessionKey)
* the same browser would never re-fire because every new Meet call reuses
* the same exe-keyed session.
*/
async clearSession(sessionKey: string): Promise<void> {
if (!this.state.notifiedSessions[sessionKey]) return;
delete this.state.notifiedSessions[sessionKey];
await this.persist();
}
async markDismissed(executable: string, now: Date = new Date()): Promise<void> {
this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() };
await this.persist();
}
async muteApp(executable: string): Promise<void> {
const key = dismissKeyFor(executable);
if (!this.state.mutedApps.includes(key)) {
this.state.mutedApps.push(key);
await this.persist();
}
}
isMuted(executable: string): boolean {
const needle = dismissKeyFor(executable);
return this.state.mutedApps.some((m) => needle.includes(m) || m.includes(needle));
}
private async persist(): Promise<void> {
this.state = gc(this.state);
try {
await this.store.save(this.state);
} catch (err) {
console.error("[MeetingDetect] failed to persist suppression state:", err);
}
}
}
function dismissKeyFor(executable: string): string {
// Reduce a path/exe to a stable key — strip directory, lowercase.
const base = executable.replace(/^.*[/\\]/, "").toLowerCase();
return base || executable.toLowerCase();
}
function gc(state: SuppressionState): SuppressionState {
const now = Date.now();
const sessions: SuppressionState["notifiedSessions"] = {};
for (const [k, v] of Object.entries(state.notifiedSessions)) {
const ts = Date.parse(v.notifiedAt);
if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v;
}
const dismissed: SuppressionState["recentlyDismissed"] = {};
for (const [k, v] of Object.entries(state.recentlyDismissed)) {
const ts = Date.parse(v.dismissedAt);
if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v;
}
return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
}
/** In-memory store for tests. */
export class InMemorySuppressionStore implements SuppressionStore {
private state: SuppressionState = empty();
async load(): Promise<SuppressionState> { return JSON.parse(JSON.stringify(this.state)); }
async save(s: SuppressionState): Promise<void> { this.state = JSON.parse(JSON.stringify(s)); }
snapshot(): SuppressionState { return JSON.parse(JSON.stringify(this.state)); }
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { buildToastHtml } from "./toast-window.js";
describe("buildToastHtml", () => {
it("renders title, subtitle, CTA and a link to the rowboat deeplink", () => {
const html = buildToastHtml({
title: "You are in a meeting",
subtitle: "Detected on Google Meet",
actionLabel: "Start taking notes",
actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet",
});
expect(html).toContain("You are in a meeting");
expect(html).toContain("Detected on Google Meet");
expect(html).toContain("Start taking notes");
expect(html).toContain("rowboat://action?type=take-meeting-notes");
});
it("includes the rowboat wordmark and accessibility attributes", () => {
const html = buildToastHtml({
title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action",
});
expect(html).toContain(">rowboat<");
expect(html).toContain('role="alert"');
expect(html).toContain('aria-live="polite"');
expect(html).toContain('aria-label="Dismiss meeting notification"');
});
it("includes a dismiss link the window will intercept", () => {
const html = buildToastHtml({
title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action",
});
expect(html).toContain("rowboat-toast://dismiss");
});
it("escapes HTML in title/subtitle so a Meet titled `<script>` can't break the toast", () => {
const html = buildToastHtml({
title: "<script>alert(1)</script>",
subtitle: "& < > \" '",
actionLabel: "ok",
actionLink: "rowboat://action",
});
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).toContain("&amp; &lt; &gt; &quot; &#39;");
});
it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => {
const html = buildToastHtml({
title: "x", subtitle: "y", actionLabel: "ok",
actionLink: `rowboat://action?title=evil"onerror=alert(1)`,
});
expect(html).not.toContain(`"onerror=alert(1)`);
expect(html).toContain("&quot;onerror=alert(1)");
});
});

View file

@ -0,0 +1,214 @@
import { BrowserWindow, screen } from "electron";
import { dispatchUrl } from "../deeplink.js";
// Notion-style meeting toast: top-center frameless window with our own HTML.
// Persistent — closes only when the user clicks the CTA or the X.
//
// Spec: white card, top: 24px, max-width 640, slide-down entry animation.
const TOAST_WIDTH = 560;
const TOAST_HEIGHT = 92;
const TOAST_TOP_MARGIN = 24;
export interface ToastPayload {
title: string;
subtitle: string;
actionLabel: string;
actionLink: string;
}
/** Build the self-contained HTML the toast window renders. Pure — tested. */
export function buildToastHtml(payload: ToastPayload): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { margin: 0; padding: 0; height: 100%; background: transparent; overflow: hidden; }
body { font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; color: #0A0A0A; }
.card {
position: relative;
box-sizing: border-box;
max-width: 560px;
width: 100%;
background: #FFFFFF;
border-radius: 14px;
padding: 16px 44px 16px 20px; /* extra right padding to clear the X */
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04);
-webkit-app-region: drag;
animation: slidein 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slidein {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.wordmark {
font-weight: 700;
font-size: 16px;
color: #0A2540;
letter-spacing: -0.01em;
-webkit-app-region: drag;
user-select: none;
}
.divider {
width: 1px;
height: 28px;
background: #E5E7EB;
flex-shrink: 0;
}
.text {
flex: 1;
min-width: 0;
-webkit-app-region: no-drag;
}
.title {
font-weight: 600;
font-size: 15px;
color: #0A0A0A;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-weight: 400;
font-size: 13px;
color: #6B7280;
line-height: 1.3;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.cta {
-webkit-app-region: no-drag;
display: inline-block;
flex-shrink: 0;
background: #0A2540;
color: #FFFFFF;
font-weight: 500;
font-size: 13px;
padding: 9px 18px;
border-radius: 8px;
text-decoration: none;
cursor: pointer;
user-select: none;
transition: background 120ms ease;
}
a.cta:hover { background: #081C33; }
a.cta:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
a.close {
-webkit-app-region: no-drag;
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #4B5563;
cursor: pointer;
transition: background 120ms ease;
}
a.close:hover { background: #F3F4F6; }
a.close:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
a.close svg { display: block; }
</style>
</head>
<body>
<div class="card" role="alert" aria-live="polite">
<div class="wordmark">rowboat</div>
<div class="divider" aria-hidden="true"></div>
<div class="text">
<div class="title">${escapeHtml(payload.title)}</div>
<div class="subtitle">${escapeHtml(payload.subtitle)}</div>
</div>
<a class="cta" href="${escapeAttr(payload.actionLink)}">${escapeHtml(payload.actionLabel)}</a>
<a class="close" href="rowboat-toast://dismiss" aria-label="Dismiss meeting notification">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</a>
</div>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]!));
}
function escapeAttr(s: string): string { return escapeHtml(s); }
export class MeetingToastWindow {
private win: BrowserWindow | null = null;
show(payload: ToastPayload): void {
// If a previous toast is still up, replace it.
this.closeImmediate();
const display = screen.getPrimaryDisplay();
const wa = display.workArea;
const x = Math.round(wa.x + (wa.width - TOAST_WIDTH) / 2);
const y = wa.y + TOAST_TOP_MARGIN;
const win = new BrowserWindow({
width: TOAST_WIDTH,
height: TOAST_HEIGHT,
x, y,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
focusable: false,
show: false,
hasShadow: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
win.setAlwaysOnTop(true, "screen-saver");
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.webContents.on("will-navigate", (event, url) => {
event.preventDefault();
if (url.startsWith("rowboat-toast://")) {
this.closeImmediate();
return;
}
if (url.startsWith("rowboat://")) {
dispatchUrl(url);
this.closeImmediate();
return;
}
});
win.once("ready-to-show", () => win.show());
win.on("closed", () => { if (this.win === win) this.win = null; });
const html = buildToastHtml(payload);
win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
this.win = win;
// No auto-dismiss — persistent until X or CTA click (per spec).
}
closeImmediate(): void {
if (this.win && !this.win.isDestroyed()) this.win.close();
this.win = null;
}
}

View file

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

View file

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

View file

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

View file

@ -3968,36 +3968,53 @@ function App() {
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
}, [])
// Triggered by main when the user clicks a calendar-meeting notification.
// Reuses the same flow as the in-app "Join meeting & take notes" button.
// Triggered by main when the user clicks a meeting-notes notification —
// either the calendar-time notification (event populated) or the mic-detect
// ad-hoc notification (event=null, title=string). Both routes feed the same
// calendar-block flow which kicks off startMeetingNow().
// When `openMeeting` is true, also opens the meeting URL in the system browser.
useEffect(() => {
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
const e = event as {
summary?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
end?: { dateTime?: string; date?: string; timeZone?: string }
location?: string
htmlLink?: string
hangoutLink?: string
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
}
if (!e || typeof e !== 'object') return
const conferenceLink = extractConferenceLink(e as Record<string, unknown>)
if (openMeeting && conferenceLink) {
window.open(conferenceLink, '_blank')
} else if (openMeeting) {
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e)
}
window.__pendingCalendarEvent = {
summary: e.summary,
start: e.start,
end: e.end,
location: e.location,
htmlLink: e.htmlLink,
conferenceLink,
source: 'calendar-sync',
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, title }) => {
const payload = event as
| {
summary?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
end?: { dateTime?: string; date?: string; timeZone?: string }
location?: string
htmlLink?: string
hangoutLink?: string
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
}
| null
| undefined
if (payload && typeof payload === 'object') {
const conferenceLink = extractConferenceLink(payload as Record<string, unknown>)
if (openMeeting && conferenceLink) {
window.open(conferenceLink, '_blank')
} else if (openMeeting) {
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', payload)
}
window.__pendingCalendarEvent = {
summary: payload.summary,
start: payload.start,
end: payload.end,
location: payload.location,
htmlLink: payload.htmlLink,
conferenceLink,
source: 'calendar-sync',
}
} else if (typeof title === 'string' && title.length > 0) {
// Ad-hoc detection — no calendar event matched. Build a minimal
// pending event from the title so the meeting flow can still start.
window.__pendingCalendarEvent = {
summary: title,
source: 'meeting-detect',
}
} else {
return
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
})
}, [])

View file

@ -395,11 +395,13 @@ const ipcSchemas = {
},
'app:takeMeetingNotes': {
req: z.object({
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
// Calendar event JSON when correlated; null for mic-detect ad-hoc fires.
event: z.unknown(),
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
// in addition to triggering the take-notes flow.
openMeeting: z.boolean().optional(),
// Fallback title for ad-hoc detection (no calendar event matched).
title: z.string().nullable().optional(),
}),
res: z.null(),
},

520
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff