fix for mac

This commit is contained in:
Arjun 2026-05-27 11:24:36 +05:30
parent 1fca31f1c7
commit 5d8eecd3dc
3 changed files with 128 additions and 15 deletions

View file

@ -11,16 +11,19 @@ export interface WindowSnapshot {
} }
/** /**
* Best-effort look at currently-open window titles for a given executable. * Best-effort look at currently-open window titles (and, on macOS, tab URLs)
* On Windows: `tasklist /v /fi "imagename eq <exe>"` fast because it skips * for a given executable. On Windows: `tasklist /v /fi "imagename eq <exe>"`
* every system process. On macOS: AppleScript for the frontmost window. * 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; * Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name.
* an empty title list means "process is running but no window has a title." * 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> { export async function getWindowSnapshot(executable?: string): Promise<WindowSnapshot | null> {
if (process.platform === "win32") return getWindowSnapshotWindows(executable); if (process.platform === "win32") return getWindowSnapshotWindows(executable);
if (process.platform === "darwin") return getWindowSnapshotMacOS(); if (process.platform === "darwin") return getWindowSnapshotMacOS(executable);
return null; return null;
} }
@ -63,10 +66,49 @@ function parseCsvLine(line: string): string[] {
return out; return out;
} }
// macOS via osascript — title of the frontmost window of the frontmost app. // Chromium-family browsers share Chrome's AppleScript dictionary (each tab
// Requires Accessibility permission for the Electron app; without it, the // exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and
// `name of front window` lookup returns empty. // anything else expose no tab scripting, so they fall back to the frontmost
const MACOS_SCRIPT = ` // 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" tell application "System Events"
set frontApp to first application process whose frontmost is true set frontApp to first application process whose frontmost is true
set appName to name of frontApp set appName to name of frontApp
@ -79,16 +121,60 @@ tell application "System Events"
end tell end tell
`.trim(); `.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 { 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, timeout: 5_000,
}); });
const [, ...titleParts] = stdout.trim().split("\n"); const [, ...titleParts] = stdout.trim().split("\n");
const title = titleParts.join("\n"); const title = titleParts.join("\n");
return { titles: title ? [title] : [] }; return { titles: title ? [title] : [] };
} catch (err) { } 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; return null;
} }
} }

View file

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

View file

@ -7,13 +7,15 @@ export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" |
interface AppRule { interface AppRule {
kind: MeetingAppKind; kind: MeetingAppKind;
// Case-insensitive substring match against the executable path / basename // 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[]; match: string[];
} }
const RULES: AppRule[] = [ const RULES: AppRule[] = [
{ kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] }, { 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: "slack", match: ["slack.exe", "slack helper", "slack"] },
{ kind: "discord", match: ["discord.exe", "discord"] }, { kind: "discord", match: ["discord.exe", "discord"] },
{ kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] }, { kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },