mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
fix for mac
This commit is contained in:
parent
1fca31f1c7
commit
5d8eecd3dc
3 changed files with 128 additions and 15 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue