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.
* On Windows: `tasklist /v /fi "imagename eq <exe>"` 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 <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"). 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<WindowSnapshot | null> {
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<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
@ -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;
}
}

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 {
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"] },