mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d8eecd3dc | ||
|
|
1fca31f1c7 | ||
|
|
e7ea03c8d1 | ||
|
|
2901379d23 | ||
|
|
8da40bd9bb | ||
|
|
6c9d9206c8 |
29 changed files with 2806 additions and 45 deletions
|
|
@ -10,7 +10,8 @@
|
|||
"start": "electron .",
|
||||
"build": "rm -rf dist && tsc && node bundle.mjs",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
"make": "electron-forge make",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/core": "workspace:*",
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"electron": "^39.2.7",
|
||||
"esbuild": "^0.24.2"
|
||||
"esbuild": "^0.24.2",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,11 @@ export function dispatchDeepLink(url: string): void {
|
|||
|
||||
interface MeetingNotesAction {
|
||||
type: "take-meeting-notes" | "join-and-take-meeting-notes";
|
||||
eventId: string;
|
||||
// eventId is required for join-and-take-meeting-notes (calendar-time fire)
|
||||
// but optional for take-meeting-notes — mic-detection ad-hoc fires use a
|
||||
// title-only payload when the call isn't on the calendar.
|
||||
eventId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
type ParsedAction = MeetingNotesAction;
|
||||
|
|
@ -76,10 +80,16 @@ function parseAction(url: string): ParsedAction | null {
|
|||
if (host !== ACTION_HOST) return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const type = params.get("type");
|
||||
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
|
||||
const eventId = params.get("eventId");
|
||||
const eventId = params.get("eventId") || undefined;
|
||||
const title = params.get("title") || undefined;
|
||||
if (type === "join-and-take-meeting-notes") {
|
||||
return eventId ? { type, eventId } : null;
|
||||
}
|
||||
if (type === "take-meeting-notes") {
|
||||
// Need at least one identifier — eventId (calendar) or title (ad-hoc).
|
||||
if (!eventId && !title) return null;
|
||||
return { type, eventId, title };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -88,25 +98,31 @@ async function dispatchAction(url: string): Promise<void> {
|
|||
if (!parsed) return;
|
||||
|
||||
const openMeeting = parsed.type === "join-and-take-meeting-notes";
|
||||
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
|
||||
await handleTakeMeetingNotes(parsed.eventId, parsed.title, openMeeting);
|
||||
}
|
||||
|
||||
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
|
||||
async function handleTakeMeetingNotes(
|
||||
eventId: string | undefined,
|
||||
title: string | undefined,
|
||||
openMeeting: boolean,
|
||||
): Promise<void> {
|
||||
const win = mainWindowRef;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
focusWindow(win);
|
||||
|
||||
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
||||
let event: unknown;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
event = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
||||
return;
|
||||
let event: unknown = null;
|
||||
if (eventId) {
|
||||
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
event = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
||||
// Fall through with event=null so the renderer can still open an ad-hoc note.
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { event, openMeeting };
|
||||
const payload = { event, openMeeting, title: title ?? null };
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,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();
|
||||
|
||||
|
|
|
|||
89
apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts
Normal file
89
apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
101
apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts
Normal file
101
apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
apps/x/apps/main/src/meeting-detect/browser-match.test.ts
Normal file
44
apps/x/apps/main/src/meeting-detect/browser-match.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { matchTitleOrUrl } from "./browser-match.js";
|
||||
|
||||
describe("matchTitleOrUrl", () => {
|
||||
it("matches Google Meet by URL", () => {
|
||||
const m = matchTitleOrUrl("Meet — Standup", "https://meet.google.com/abc-defg-hij");
|
||||
expect(m?.platform).toBe("google-meet");
|
||||
});
|
||||
|
||||
it("matches Google Meet by window title alone (Windows/Mac no-URL case)", () => {
|
||||
const m = matchTitleOrUrl("Meet - Daily Standup - Google Chrome", undefined);
|
||||
expect(m?.platform).toBe("google-meet");
|
||||
});
|
||||
|
||||
it("matches Meet with em-dash variant (locale-dependent title)", () => {
|
||||
const m = matchTitleOrUrl("Meet — Daily Standup", undefined);
|
||||
expect(m?.platform).toBe("google-meet");
|
||||
});
|
||||
|
||||
it("matches Zoom web client", () => {
|
||||
const m = matchTitleOrUrl("Zoom Meeting", "https://us02web.zoom.us/j/123456789");
|
||||
expect(m?.platform).toBe("zoom-web");
|
||||
});
|
||||
|
||||
it("matches Teams web", () => {
|
||||
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
|
||||
expect(m?.platform).toBe("teams-web");
|
||||
});
|
||||
|
||||
it("ignores random YouTube tab", () => {
|
||||
const m = matchTitleOrUrl("Mock Interview - YouTube", "https://www.youtube.com/watch?v=abc");
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(matchTitleOrUrl(undefined, undefined)).toBeNull();
|
||||
expect(matchTitleOrUrl("", "")).toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const m = matchTitleOrUrl("ZOOM MEETING", "https://ZOOM.US/J/999");
|
||||
expect(m?.platform).toBe("zoom-web");
|
||||
});
|
||||
});
|
||||
66
apps/x/apps/main/src/meeting-detect/browser-match.ts
Normal file
66
apps/x/apps/main/src/meeting-detect/browser-match.ts
Normal 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;
|
||||
}
|
||||
111
apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts
Normal file
111
apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { correlateFromDir } from "./calendar-correlate.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rb-meeting-detect-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeEvent(name: string, body: unknown): Promise<void> {
|
||||
await fs.writeFile(path.join(tmpDir, `${name}.json`), JSON.stringify(body), "utf-8");
|
||||
}
|
||||
|
||||
function evt(opts: {
|
||||
id: string;
|
||||
summary: string;
|
||||
startMinutes: number; // minutes from `anchor`
|
||||
endMinutes: number;
|
||||
cancelled?: boolean;
|
||||
declined?: boolean;
|
||||
hangoutLink?: string;
|
||||
}): unknown {
|
||||
const anchor = new Date("2026-05-15T10:00:00Z").getTime();
|
||||
return {
|
||||
id: opts.id,
|
||||
summary: opts.summary,
|
||||
status: opts.cancelled ? "cancelled" : "confirmed",
|
||||
start: { dateTime: new Date(anchor + opts.startMinutes * 60_000).toISOString() },
|
||||
end: { dateTime: new Date(anchor + opts.endMinutes * 60_000).toISOString() },
|
||||
attendees: [
|
||||
{ self: true, responseStatus: opts.declined ? "declined" : "accepted" },
|
||||
{ email: "alice@example.com", displayName: "Alice" },
|
||||
],
|
||||
hangoutLink: opts.hangoutLink,
|
||||
};
|
||||
}
|
||||
|
||||
describe("correlateFromDir", () => {
|
||||
const NOW = new Date("2026-05-15T10:30:00Z");
|
||||
|
||||
it("returns null when the directory does not exist", async () => {
|
||||
const result = await correlateFromDir(path.join(tmpDir, "does-not-exist"), NOW);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no events overlap", async () => {
|
||||
await writeEvent("e1", evt({ id: "e1", summary: "Morning", startMinutes: -120, endMinutes: -60 }));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("matches an event in progress", async () => {
|
||||
await writeEvent("e1", evt({
|
||||
id: "e1",
|
||||
summary: "Q2 Planning",
|
||||
startMinutes: 25, // 10:25, NOW=10:30 → in progress
|
||||
endMinutes: 55,
|
||||
hangoutLink: "https://meet.google.com/abc",
|
||||
}));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result?.eventId).toBe("e1");
|
||||
expect(result?.summary).toBe("Q2 Planning");
|
||||
expect(result?.meetingUrl).toBe("https://meet.google.com/abc");
|
||||
expect(result?.attendees).toHaveLength(1); // self filtered
|
||||
expect(result?.attendees[0].email).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("matches an event starting within pre-roll", async () => {
|
||||
await writeEvent("e1", evt({
|
||||
id: "e1",
|
||||
summary: "Upcoming",
|
||||
startMinutes: 31, // NOW=10:30, event at 10:31 → 1 min away, within 2-min pre-roll
|
||||
endMinutes: 60,
|
||||
}));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result?.eventId).toBe("e1");
|
||||
});
|
||||
|
||||
it("ignores cancelled events", async () => {
|
||||
await writeEvent("e1", evt({ id: "e1", summary: "Dead", startMinutes: 25, endMinutes: 55, cancelled: true }));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores events the user declined", async () => {
|
||||
await writeEvent("e1", evt({ id: "e1", summary: "Nope", startMinutes: 25, endMinutes: 55, declined: true }));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the closest event when multiple overlap", async () => {
|
||||
await writeEvent("far", evt({ id: "far", summary: "Far", startMinutes: -10, endMinutes: 35 }));
|
||||
await writeEvent("near", evt({ id: "near", summary: "Near", startMinutes: 29, endMinutes: 59 }));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result?.eventId).toBe("near");
|
||||
});
|
||||
|
||||
it("ignores sync_state.json", async () => {
|
||||
await writeEvent("sync_state", { lastSync: "whatever" });
|
||||
await writeEvent("e1", evt({ id: "e1", summary: "Real", startMinutes: 25, endMinutes: 55 }));
|
||||
const result = await correlateFromDir(tmpDir, NOW);
|
||||
expect(result?.eventId).toBe("e1");
|
||||
});
|
||||
});
|
||||
123
apps/x/apps/main/src/meeting-detect/calendar-correlate.ts
Normal file
123
apps/x/apps/main/src/meeting-detect/calendar-correlate.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
||||
|
||||
// Match a detection event against the user's synced calendar. The detector
|
||||
// fires when the mic flips on; if there's a calendar event currently in
|
||||
// progress (or about to start / just ended), we attach its metadata so the
|
||||
// popup can show the right title and the deeplink can target the right note.
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
||||
|
||||
// Pre-roll: someone joining 2 min early should still match the upcoming event.
|
||||
const PRE_ROLL_MS = 2 * 60 * 1000;
|
||||
// Post-roll: someone joining 2 min late (or a meeting that ran long and the
|
||||
// next-event window already started) should still match.
|
||||
const POST_ROLL_MS = 2 * 60 * 1000;
|
||||
|
||||
interface CalendarEventFile {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
start?: { dateTime?: string };
|
||||
end?: { dateTime?: string };
|
||||
attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>;
|
||||
hangoutLink?: string;
|
||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> };
|
||||
}
|
||||
|
||||
export interface CorrelatedEvent {
|
||||
eventId: string;
|
||||
summary: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
attendees: Array<{ email?: string; displayName?: string }>;
|
||||
meetingUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a calendar event whose [start - PRE_ROLL, end + POST_ROLL] window
|
||||
* contains `now`. Returns the closest match (smallest |now - start|) when
|
||||
* multiple events overlap (back-to-back meetings).
|
||||
*/
|
||||
export async function correlateNow(now: Date = new Date()): Promise<CorrelatedEvent | null> {
|
||||
return correlateFromDir(CALENDAR_SYNC_DIR, now);
|
||||
}
|
||||
|
||||
/** Exposed for tests — accepts an arbitrary directory of calendar JSON files. */
|
||||
export async function correlateFromDir(dir: string, now: Date): Promise<CorrelatedEvent | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowMs = now.getTime();
|
||||
let best: { event: CorrelatedEvent; distance: number } | null = null;
|
||||
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
if (name === "sync_state.json" || name.startsWith("sync_state")) continue;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(dir, name), "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let event: CalendarEventFile;
|
||||
try {
|
||||
event = JSON.parse(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.status === "cancelled") continue;
|
||||
if (isDeclinedBySelf(event)) continue;
|
||||
|
||||
const startStr = event.start?.dateTime;
|
||||
const endStr = event.end?.dateTime;
|
||||
if (!startStr || !endStr) continue;
|
||||
|
||||
const startMs = Date.parse(startStr);
|
||||
const endMs = Date.parse(endStr);
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
||||
|
||||
// Skip events outside the active window.
|
||||
if (nowMs < startMs - PRE_ROLL_MS) continue;
|
||||
if (nowMs > endMs + POST_ROLL_MS) continue;
|
||||
|
||||
const eventId = event.id || name.replace(/\.json$/, "");
|
||||
const correlated: CorrelatedEvent = {
|
||||
eventId,
|
||||
summary: event.summary?.trim() || "Untitled meeting",
|
||||
startMs,
|
||||
endMs,
|
||||
attendees: (event.attendees || [])
|
||||
.filter((a) => !a.self)
|
||||
.map((a) => ({ email: a.email, displayName: a.displayName })),
|
||||
meetingUrl: extractMeetingUrl(event),
|
||||
};
|
||||
|
||||
const distance = Math.abs(nowMs - startMs);
|
||||
if (!best || distance < best.distance) {
|
||||
best = { event: correlated, distance };
|
||||
}
|
||||
}
|
||||
|
||||
return best?.event ?? null;
|
||||
}
|
||||
|
||||
function isDeclinedBySelf(event: CalendarEventFile): boolean {
|
||||
if (!event.attendees) return false;
|
||||
const self = event.attendees.find((a) => a.self);
|
||||
return self?.responseStatus === "declined";
|
||||
}
|
||||
|
||||
function extractMeetingUrl(event: CalendarEventFile): string | undefined {
|
||||
if (event.hangoutLink) return event.hangoutLink;
|
||||
const eps = event.conferenceData?.entryPoints || [];
|
||||
const video = eps.find((e) => e.entryPointType === "video");
|
||||
return video?.uri;
|
||||
}
|
||||
134
apps/x/apps/main/src/meeting-detect/detector.test.ts
Normal file
134
apps/x/apps/main/src/meeting-detect/detector.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { MeetingDetector, type MeetingActiveEvent, type MeetingClearedEvent } from "./detector.js";
|
||||
import type { MicProbe, MicUser } from "./types.js";
|
||||
|
||||
class FakeProbe implements MicProbe {
|
||||
private next: MicUser[] = [];
|
||||
setNext(users: MicUser[]): void { this.next = users; }
|
||||
async probe(): Promise<MicUser[]> { return this.next; }
|
||||
}
|
||||
|
||||
function collect(detector: MeetingDetector) {
|
||||
const active: MeetingActiveEvent[] = [];
|
||||
const cleared: MeetingClearedEvent[] = [];
|
||||
detector.on("meeting-active", (e) => active.push(e));
|
||||
detector.on("meeting-cleared", (e) => cleared.push(e));
|
||||
return { active, cleared };
|
||||
}
|
||||
|
||||
describe("MeetingDetector", () => {
|
||||
let probe: FakeProbe;
|
||||
let detector: MeetingDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
probe = new FakeProbe();
|
||||
// tickMs is irrelevant — we drive ticks manually.
|
||||
detector = new MeetingDetector(probe, 999_999);
|
||||
});
|
||||
|
||||
it("emits meeting-active once when a Zoom-like exe appears", async () => {
|
||||
const { active } = collect(detector);
|
||||
|
||||
probe.setNext([{ executable: "C:\\Program Files\\Zoom\\bin\\Zoom.exe" }]);
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].kind).toBe("zoom");
|
||||
expect(active[0].executable).toContain("Zoom.exe");
|
||||
});
|
||||
|
||||
it("does not re-emit while the same exe keeps appearing", async () => {
|
||||
const { active } = collect(detector);
|
||||
const user = { executable: "/Applications/zoom.us.app/Contents/MacOS/zoom.us", pid: 4711 };
|
||||
|
||||
probe.setNext([user]);
|
||||
await detector.tick();
|
||||
await detector.tick();
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits meeting-cleared when the exe disappears", async () => {
|
||||
const { active, cleared } = collect(detector);
|
||||
const user = { executable: "zoom.us", pid: 4711 };
|
||||
|
||||
probe.setNext([user]);
|
||||
await detector.tick();
|
||||
|
||||
probe.setNext([]);
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(cleared).toHaveLength(1);
|
||||
expect(cleared[0].sessionKey).toBe(active[0].sessionKey);
|
||||
});
|
||||
|
||||
it("ignores unknown executables (Voice Memos, OBS, etc.)", async () => {
|
||||
const { active, cleared } = collect(detector);
|
||||
|
||||
probe.setNext([{ executable: "Voice Memos", pid: 999 }]);
|
||||
await detector.tick();
|
||||
|
||||
probe.setNext([]);
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(0);
|
||||
expect(cleared).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("classifies a browser as kind=browser (for downstream tab-title check)", async () => {
|
||||
const { active } = collect(detector);
|
||||
|
||||
probe.setNext([{ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", pid: 5050 }]);
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].kind).toBe("browser");
|
||||
});
|
||||
|
||||
it("treats a relaunched app (new pid) as a new session on macOS", async () => {
|
||||
const { active, cleared } = collect(detector);
|
||||
|
||||
probe.setNext([{ executable: "zoom.us", pid: 100 }]);
|
||||
await detector.tick();
|
||||
|
||||
probe.setNext([]); // app closed
|
||||
await detector.tick();
|
||||
|
||||
probe.setNext([{ executable: "zoom.us", pid: 200 }]); // re-opened
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(2);
|
||||
expect(cleared).toHaveLength(1);
|
||||
expect(active[0].sessionKey).not.toBe(active[1].sessionKey);
|
||||
});
|
||||
|
||||
it("handles multiple concurrent meeting apps independently", async () => {
|
||||
const { active, cleared } = collect(detector);
|
||||
|
||||
probe.setNext([
|
||||
{ executable: "zoom.us", pid: 100 },
|
||||
{ executable: "Microsoft Teams", pid: 200 },
|
||||
]);
|
||||
await detector.tick();
|
||||
|
||||
probe.setNext([{ executable: "Microsoft Teams", pid: 200 }]);
|
||||
await detector.tick();
|
||||
|
||||
expect(active).toHaveLength(2);
|
||||
expect(active.map((e) => e.kind).sort()).toEqual(["teams", "zoom"]);
|
||||
expect(cleared).toHaveLength(1);
|
||||
expect(cleared[0].sessionKey).toContain("zoom.us");
|
||||
});
|
||||
|
||||
it("recovers without crashing when the probe throws", async () => {
|
||||
const flaky: MicProbe = { probe: vi.fn().mockRejectedValueOnce(new Error("boom")) };
|
||||
const d = new MeetingDetector(flaky, 999_999);
|
||||
// tick() awaits probe.probe() so a rejection bubbles — start() catches it. Verify start() doesn't throw.
|
||||
d.start();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
d.stop();
|
||||
expect(flaky.probe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
107
apps/x/apps/main/src/meeting-detect/detector.ts
Normal file
107
apps/x/apps/main/src/meeting-detect/detector.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { classifyExecutable, type MeetingAppKind } from "./meeting-apps.js";
|
||||
import type { MicProbe, MicUser } from "./types.js";
|
||||
|
||||
const DEFAULT_TICK_MS = 3_000;
|
||||
|
||||
export interface MeetingActiveEvent {
|
||||
executable: string;
|
||||
pid?: number;
|
||||
kind: MeetingAppKind;
|
||||
// Stable key for dedup — exe path (plus pid on mac so a Zoom relaunch counts as a new session).
|
||||
sessionKey: string;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
export interface MeetingClearedEvent {
|
||||
sessionKey: string;
|
||||
endedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a platform-specific MicProbe and emits when a whitelisted meeting app
|
||||
* starts / stops holding the mic. One emit per distinct session — a session
|
||||
* lasts as long as the same exe (+pid on macOS) keeps appearing in probe
|
||||
* results across ticks.
|
||||
*
|
||||
* Pure logic; UI/notification wiring lives in the service layer. Probe is
|
||||
* injected so this is testable without a real OS.
|
||||
*/
|
||||
export class MeetingDetector extends EventEmitter {
|
||||
private readonly probe: MicProbe;
|
||||
private readonly tickMs: number;
|
||||
private active = new Map<string, MeetingActiveEvent>();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(probe: MicProbe, tickMs: number = DEFAULT_TICK_MS) {
|
||||
super();
|
||||
this.probe = probe;
|
||||
this.tickMs = tickMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
const loop = async () => {
|
||||
if (!this.running) return;
|
||||
try {
|
||||
await this.tick();
|
||||
} catch (err) {
|
||||
console.error("[MeetingDetect] tick failed:", err);
|
||||
}
|
||||
if (this.running) this.timer = setTimeout(loop, this.tickMs);
|
||||
};
|
||||
this.running = true;
|
||||
// Run first tick immediately; subsequent ticks scheduled by the loop.
|
||||
this.timer = setTimeout(loop, 0);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for tests — drive a single probe-and-diff cycle. */
|
||||
async tick(): Promise<void> {
|
||||
const users = await this.probe.probe();
|
||||
const seenKeys = new Set<string>();
|
||||
const now = new Date();
|
||||
|
||||
for (const user of users) {
|
||||
const kind = classifyExecutable(user.executable);
|
||||
if (kind === "unknown") continue;
|
||||
|
||||
const key = sessionKey(user);
|
||||
seenKeys.add(key);
|
||||
|
||||
if (!this.active.has(key)) {
|
||||
const event: MeetingActiveEvent = {
|
||||
executable: user.executable,
|
||||
pid: user.pid,
|
||||
kind,
|
||||
sessionKey: key,
|
||||
startedAt: now,
|
||||
};
|
||||
this.active.set(key, event);
|
||||
this.emit("meeting-active", event);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, event] of this.active) {
|
||||
if (seenKeys.has(key)) continue;
|
||||
this.active.delete(key);
|
||||
const cleared: MeetingClearedEvent = { sessionKey: key, endedAt: now };
|
||||
this.emit("meeting-cleared", cleared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionKey(user: MicUser): string {
|
||||
// On macOS we include pid so an app relaunch counts as a new session.
|
||||
// On Windows there's no pid; the exe path alone is sufficient because
|
||||
// Windows can't tell us *which instance* of an exe is holding the mic.
|
||||
return user.pid !== undefined ? `${user.executable}#${user.pid}` : user.executable;
|
||||
}
|
||||
180
apps/x/apps/main/src/meeting-detect/foreground-window.ts
Normal file
180
apps/x/apps/main/src/meeting-detect/foreground-window.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
apps/x/apps/main/src/meeting-detect/index.ts
Normal file
26
apps/x/apps/main/src/meeting-detect/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { MeetingDetector } from "./detector.js";
|
||||
import { WindowsMicProbe } from "./probe-windows.js";
|
||||
import { MacOsMicProbe } from "./probe-macos.js";
|
||||
import type { MicProbe } from "./types.js";
|
||||
|
||||
export { MeetingDetector } from "./detector.js";
|
||||
export type { MeetingActiveEvent, MeetingClearedEvent } from "./detector.js";
|
||||
export { classifyExecutable, isMeetingApp, isBrowser } from "./meeting-apps.js";
|
||||
export type { MeetingAppKind } from "./meeting-apps.js";
|
||||
export type { MicProbe, MicUser } from "./types.js";
|
||||
export { Suppression, InMemorySuppressionStore } from "./suppression.js";
|
||||
export type { SuppressionStore } from "./suppression.js";
|
||||
export { MeetingDetectService, buildPopup } from "./service.js";
|
||||
export type { MeetingDetectServiceOptions } from "./service.js";
|
||||
|
||||
export function createPlatformDetector(): MeetingDetector | null {
|
||||
const probe = createPlatformProbe();
|
||||
if (!probe) return null;
|
||||
return new MeetingDetector(probe);
|
||||
}
|
||||
|
||||
function createPlatformProbe(): MicProbe | null {
|
||||
if (process.platform === "win32") return new WindowsMicProbe();
|
||||
if (process.platform === "darwin") return new MacOsMicProbe();
|
||||
return null;
|
||||
}
|
||||
25
apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts
Normal file
25
apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
51
apps/x/apps/main/src/meeting-detect/meeting-apps.ts
Normal file
51
apps/x/apps/main/src/meeting-detect/meeting-apps.ts
Normal 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";
|
||||
}
|
||||
47
apps/x/apps/main/src/meeting-detect/probe-macos.ts
Normal file
47
apps/x/apps/main/src/meeting-detect/probe-macos.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { MicProbe, MicUser } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// macOS doesn't expose a public "who is using the mic right now" API. Two
|
||||
// pragmatic signals we can read from a shell without a native helper:
|
||||
//
|
||||
// 1. `pmset -g assertions` — apps in a video call almost always hold a
|
||||
// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong
|
||||
// proxy for "active call." False positives: video playback (YouTube,
|
||||
// Netflix) — Phase 2's tab-title check filters those out for browsers.
|
||||
//
|
||||
// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and
|
||||
// doesn't always include the mic user, so we prefer pmset as primary.
|
||||
//
|
||||
// Output format from `pmset -g assertions`:
|
||||
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
|
||||
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
|
||||
|
||||
export class MacOsMicProbe implements MicProbe {
|
||||
async probe(): Promise<MicUser[]> {
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
console.error("[MeetingDetect] macOS probe failed:", err);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Map<number, MicUser>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = ASSERTION_LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const pid = Number(m[1]);
|
||||
const command = m[2].trim();
|
||||
if (!Number.isFinite(pid)) continue;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.set(pid, { executable: command, pid });
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
}
|
||||
85
apps/x/apps/main/src/meeting-detect/probe-windows.ts
Normal file
85
apps/x/apps/main/src/meeting-detect/probe-windows.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { MicProbe, MicUser } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Windows records every mic-using app under CapabilityAccessManager. Each app
|
||||
// subkey has LastUsedTimeStart and LastUsedTimeStop (FILETIME, int64). When
|
||||
// Start > Stop, the app is currently holding the mic. Subkey names under
|
||||
// NonPackaged are the executable path with `\` replaced by `#`.
|
||||
//
|
||||
// We shell out to PowerShell (single Get-ChildItem walk) rather than pulling
|
||||
// in a native registry binding — far simpler to ship inside Electron and the
|
||||
// poll cadence is 3s, so spawn cost is irrelevant.
|
||||
const POWERSHELL_SCRIPT = `
|
||||
$paths = @(
|
||||
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged',
|
||||
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'
|
||||
)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
foreach ($p in $paths) {
|
||||
if (-not (Test-Path $p)) { continue }
|
||||
Get-ChildItem -Path $p -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue
|
||||
if ($null -eq $props) { return }
|
||||
$start = $props.LastUsedTimeStart
|
||||
$stop = $props.LastUsedTimeStop
|
||||
if ($null -ne $start -and $null -ne $stop -and $start -gt $stop) {
|
||||
[void]$out.Add([PSCustomObject]@{ Name = $_.PSChildName })
|
||||
}
|
||||
}
|
||||
}
|
||||
$out | ConvertTo-Json -Compress
|
||||
`.trim();
|
||||
|
||||
interface RawRow {
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
function decodeNonPackagedName(name: string): string {
|
||||
// NonPackaged subkeys: "C:#Program Files#Zoom#bin#Zoom.exe" → "C:\Program Files\Zoom\bin\Zoom.exe"
|
||||
// Packaged subkeys are AUMIDs (e.g. "Microsoft.Teams_..._mscorlib") — leave as-is.
|
||||
if (name.includes("#") && !name.includes("\\")) {
|
||||
return name.replace(/#/g, "\\");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export class WindowsMicProbe implements MicProbe {
|
||||
async probe(): Promise<MicUser[]> {
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await execFileAsync(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT],
|
||||
{ timeout: 10_000, windowsHide: true },
|
||||
);
|
||||
stdout = result.stdout.trim();
|
||||
} catch (err) {
|
||||
console.error("[MeetingDetect] Windows probe failed:", err);
|
||||
return [];
|
||||
}
|
||||
if (!stdout) return [];
|
||||
|
||||
let parsed: RawRow[] | RawRow;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (err) {
|
||||
console.error("[MeetingDetect] Windows probe parse failed:", err);
|
||||
return [];
|
||||
}
|
||||
// ConvertTo-Json emits a single object (not an array) when the list has one item.
|
||||
const rows: RawRow[] = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const seen = new Set<string>();
|
||||
const out: MicUser[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row.Name !== "string") continue;
|
||||
const exe = decodeNonPackagedName(row.Name);
|
||||
if (seen.has(exe)) continue;
|
||||
seen.add(exe);
|
||||
out.push({ executable: exe });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
206
apps/x/apps/main/src/meeting-detect/service.test.ts
Normal file
206
apps/x/apps/main/src/meeting-detect/service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
221
apps/x/apps/main/src/meeting-detect/service.ts
Normal file
221
apps/x/apps/main/src/meeting-detect/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
78
apps/x/apps/main/src/meeting-detect/suppression.test.ts
Normal file
78
apps/x/apps/main/src/meeting-detect/suppression.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
163
apps/x/apps/main/src/meeting-detect/suppression.ts
Normal file
163
apps/x/apps/main/src/meeting-detect/suppression.ts
Normal 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)); }
|
||||
}
|
||||
56
apps/x/apps/main/src/meeting-detect/toast-window.test.ts
Normal file
56
apps/x/apps/main/src/meeting-detect/toast-window.test.ts
Normal 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("<script>alert(1)</script>");
|
||||
expect(html).toContain("& < > " '");
|
||||
});
|
||||
|
||||
it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => {
|
||||
const html = buildToastHtml({
|
||||
title: "x", subtitle: "y", actionLabel: "ok",
|
||||
actionLink: `rowboat://action?title=evil"onerror=alert(1)`,
|
||||
});
|
||||
expect(html).not.toContain(`"onerror=alert(1)`);
|
||||
expect(html).toContain(""onerror=alert(1)");
|
||||
});
|
||||
});
|
||||
214
apps/x/apps/main/src/meeting-detect/toast-window.ts
Normal file
214
apps/x/apps/main/src/meeting-detect/toast-window.ts
Normal 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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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;
|
||||
}
|
||||
}
|
||||
12
apps/x/apps/main/src/meeting-detect/types.ts
Normal file
12
apps/x/apps/main/src/meeting-detect/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export interface MicUser {
|
||||
// Best-effort executable identifier — full path on Windows, command name on macOS.
|
||||
executable: string;
|
||||
// Process id when the platform exposes it (macOS via lsof). Undefined on Windows
|
||||
// because the registry only records the exe path, not which pid is currently
|
||||
// holding the mic.
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface MicProbe {
|
||||
probe(): Promise<MicUser[]>;
|
||||
}
|
||||
|
|
@ -10,5 +10,8 @@
|
|||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
8
apps/x/apps/main/vitest.config.ts
Normal file
8
apps/x/apps/main/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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'))
|
||||
})
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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
520
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue