diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index e9f523d0..4f4ac579 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -11,16 +11,19 @@ export interface WindowSnapshot { } /** - * Best-effort look at currently-open window titles for a given executable. - * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips - * every system process. On macOS: AppleScript for the frontmost window. + * Best-effort look at currently-open window titles (and, on macOS, tab URLs) + * for a given executable. On Windows: `tasklist /v /fi "imagename eq "` — + * fast because it skips every system process. On macOS: AppleScript that + * enumerates every browser tab (URL + title) for Chromium-family browsers and + * Safari, falling back to the frontmost window title for everything else. * - * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; - * an empty title list means "process is running but no window has a title." + * Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name. + * Returns null on failure; an empty title list means "process is running but no + * window/tab title is available." */ export async function getWindowSnapshot(executable?: string): Promise { if (process.platform === "win32") return getWindowSnapshotWindows(executable); - if (process.platform === "darwin") return getWindowSnapshotMacOS(); + if (process.platform === "darwin") return getWindowSnapshotMacOS(executable); return null; } @@ -63,10 +66,49 @@ function parseCsvLine(line: string): string[] { return out; } -// macOS via osascript — title of the frontmost window of the frontmost app. -// Requires Accessibility permission for the Electron app; without it, the -// `name of front window` lookup returns empty. -const MACOS_SCRIPT = ` +// Chromium-family browsers share Chrome's AppleScript dictionary (each tab +// exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and +// anything else expose no tab scripting, so they fall back to the frontmost +// window title. Keyed by a substring of the pmset process name. +const CHROMIUM_APPS: Record = { + "google chrome": "Google Chrome", + "brave browser": "Brave Browser", + "microsoft edge": "Microsoft Edge", + "vivaldi": "Vivaldi", + "opera": "Opera", + "arc": "Arc", +}; + +function browserApp(executable?: string): { app: string; titleProp: "title" | "name" } | null { + const e = (executable ?? "").toLowerCase(); + for (const [needle, app] of Object.entries(CHROMIUM_APPS)) { + if (e.includes(needle)) return { app, titleProp: "title" }; + } + if (e.includes("safari")) return { app: "Safari", titleProp: "name" }; + return null; +} + +// Walk every window/tab of a browser and emit "\n" per tab. We need +// ALL tabs, not just the frontmost: the user is often looking at another app +// (e.g. taking notes) while the Meet/Zoom/Teams tab sits in the background. +function tabEnumScript(app: string, titleProp: "title" | "name"): string { + return [ + `tell application "${app}"`, + ` set _out to ""`, + ` repeat with _w in windows`, + ` repeat with _t in tabs of _w`, + ` set _out to _out & (URL of _t) & linefeed & (${titleProp} of _t) & linefeed`, + ` end repeat`, + ` end repeat`, + ` return _out`, + `end tell`, + ].join("\n"); +} + +// Frontmost window title — needs Accessibility permission. Last-resort signal +// for Firefox/unknown browsers (no tab scripting) or when tab enumeration is +// blocked. +const FRONT_WINDOW_SCRIPT = ` tell application "System Events" set frontApp to first application process whose frontmost is true set appName to name of frontApp @@ -79,16 +121,60 @@ tell application "System Events" end tell `.trim(); -async function getWindowSnapshotMacOS(): Promise<WindowSnapshot | null> { +function isPermissionError(err: unknown): boolean { + // osascript denied by TCC: Automation (-1743) or Accessibility (-1719). + const msg = err instanceof Error ? `${err.message} ${(err as { stderr?: string }).stderr ?? ""}` : String(err); + return msg.includes("-1743") || msg.includes("-1719") || /not authoriz|not allowed/i.test(msg); +} + +async function getWindowSnapshotMacOS(executable?: string): Promise<WindowSnapshot | null> { + const browser = browserApp(executable); + if (browser) { + const tabs = await enumerateBrowserTabs(browser.app, browser.titleProp); + if (tabs && tabs.length > 0) return { titles: tabs }; + // Empty/blocked → fall through to the frontmost-window title below. + } + return frontmostWindowTitle(); +} + +async function enumerateBrowserTabs(app: string, titleProp: "title" | "name"): Promise<string[] | null> { try { - const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", tabEnumScript(app, titleProp)], { + timeout: 5_000, + maxBuffer: 4 * 1024 * 1024, + }); + // Each tab contributed a URL line and a title line; both feed matchTitleOrUrl. + return stdout.split("\n").map((l) => l.trim()).filter(Boolean); + } catch (err) { + if (isPermissionError(err)) { + console.warn( + `[MeetingDetect] cannot read ${app} tabs — grant Automation permission in ` + + `System Settings → Privacy & Security → Automation (Rowboat → ${app}). Falling back to window title.`, + ); + } else { + console.error(`[MeetingDetect] tab enumeration (${app}) failed:`, err); + } + return null; + } +} + +async function frontmostWindowTitle(): Promise<WindowSnapshot | null> { + try { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", FRONT_WINDOW_SCRIPT], { timeout: 5_000, }); const [, ...titleParts] = stdout.trim().split("\n"); const title = titleParts.join("\n"); return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + if (isPermissionError(err)) { + console.warn( + "[MeetingDetect] cannot read the frontmost window title — grant Accessibility " + + "permission in System Settings → Privacy & Security → Accessibility (Rowboat).", + ); + } else { + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + } return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts new file mode 100644 index 00000000..c0072509 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { classifyExecutable } from "./meeting-apps.js"; + +describe("classifyExecutable", () => { + it("classifies Zoom on both platforms", () => { + expect(classifyExecutable("Zoom.exe")).toBe("zoom"); // Windows + expect(classifyExecutable("zoom.us")).toBe("zoom"); // macOS pmset name + }); + + it("classifies the new Teams client by its macOS/Windows process name", () => { + expect(classifyExecutable("MSTeams")).toBe("teams"); // macOS pmset name + expect(classifyExecutable("ms-teams.exe")).toBe("teams"); // Windows + expect(classifyExecutable("Microsoft Teams")).toBe("teams"); // classic + }); + + it("classifies browsers as the browser kind", () => { + expect(classifyExecutable("Google Chrome")).toBe("browser"); + expect(classifyExecutable("Safari")).toBe("browser"); + }); + + it("returns unknown for unrelated processes", () => { + expect(classifyExecutable("Finder")).toBe("unknown"); + expect(classifyExecutable("WindowServer")).toBe("unknown"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts index a18dec0f..fc1b35b4 100644 --- a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts @@ -7,13 +7,15 @@ export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | interface AppRule { kind: MeetingAppKind; // Case-insensitive substring match against the executable path / basename - // (Windows: full exe path from registry; macOS: command name from lsof). + // (Windows: full exe path from registry; macOS: process name from pmset). match: string[]; } const RULES: AppRule[] = [ { kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] }, - { kind: "teams", match: ["ms-teams.exe", "teams.exe", "microsoft teams"] }, + // "msteams" covers the current macOS/Windows process name (the new Teams ships + // as MSTeams); the others cover the classic client and the AUMID/bundle forms. + { kind: "teams", match: ["ms-teams.exe", "teams.exe", "msteams", "microsoft teams"] }, { kind: "slack", match: ["slack.exe", "slack helper", "slack"] }, { kind: "discord", match: ["discord.exe", "discord"] }, { kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },