diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.test.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.test.ts new file mode 100644 index 000000000000..651eeb269868 --- /dev/null +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vite-plus/test"; + +import { readAccessibleWindowContextWithApp } from "./SnapShotAccessibility.ts"; + +const bounds = { x: 0, y: 33, width: 1470, height: 857 }; +const window = { + name: "Issues · pingdotgg/t3code - Google Chrome – Profile", + role: "window", + bounds, + children: async () => [], + tree: async () => ({ + role: "window", + name: "Issues · pingdotgg/t3code", + children: [{ role: "link", name: "Issues" }], + }), +}; +const App = { + byPid: async (pid: number) => ({ children: async () => (pid === 42 ? [window] : []) }), +} as unknown as Parameters[0]; + +const request = { + active: { + title: "Issues · pingdotgg/t3code", + bounds, + owner: { processId: 42, bundleId: "com.google.Chrome" }, + }, + platform: "darwin" as const, + sourceTitle: "Issues · pingdotgg/t3code", + imageSize: { width: 1470, height: 857 }, +}; + +it("includes Chrome accessibility context when macOS adds a profile to its title", async () => { + const context = await readAccessibleWindowContextWithApp(App, request); + expect(context?.accessibleText).toContain("Issues"); + expect(context?.accessibility).toBeDefined(); +}); + +it("does not borrow browser accessibility from another process or application", async () => { + for (const owner of [ + { processId: 43, bundleId: "com.google.Chrome" }, + { processId: 42, bundleId: "com.example.Editor" }, + ]) { + expect( + await readAccessibleWindowContextWithApp(App, { + ...request, + active: { ...request.active, owner }, + }), + ).toBeUndefined(); + } +}); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index bf20322e14a7..96c87ff4e5df 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -20,7 +20,7 @@ export type AccessibleWindowIdentity = { readonly title: string; readonly bounds: Electron.Rectangle; readonly clientBounds?: Electron.Rectangle; - readonly owner: { readonly processId: number }; + readonly owner: { readonly processId: number; readonly bundleId?: string }; readonly accessibilityBoundsReliable?: boolean; }; @@ -112,7 +112,14 @@ async function readCapturedWindowAccessibility( const matchMode = isWaylandSession(platform, process.env) ? "wayland" : "screen-bounds"; const window = findAccessibleWindow( windows, - { title: active.title, sourceTitle, bounds: active.bounds, clientBounds: active.clientBounds }, + { + title: active.title, + sourceTitle, + bounds: active.bounds, + clientBounds: active.clientBounds, + platform, + bundleId: active.owner.bundleId, + }, matchMode, ); if (!window) { diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index ca9a97a2492a..039918ee6eb5 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -315,6 +315,89 @@ describe("findAccessibleWindow", () => { bounds: { x: 100, y: 200, width: 800, height: 600 }, }; + const chromeCapture = { + title: "Issues · pingdotgg/t3code", + bounds: { x: 0, y: 33, width: 1470, height: 857 }, + platform: "darwin" as const, + bundleId: "com.google.Chrome", + }; + + it.each([ + ["com.google.Chrome", "Google Chrome"], + ["com.google.Chrome.beta", "Google Chrome"], + ["com.google.Chrome.dev", "Google Chrome"], + ["com.google.Chrome.canary", "Google Chrome"], + ["org.chromium.Chromium", "Chromium"], + ])("matches a macOS browser profile suffix for %s", (bundleId, browserName) => { + const window = { + name: `${chromeCapture.title} - ${browserName} – Profile`, + bounds: chromeCapture.bounds, + }; + expect(findAccessibleWindow([window], { ...chromeCapture, bundleId })).toBe(window); + }); + + it("matches a Chrome title without a profile suffix", () => { + const window = { + name: `${chromeCapture.title} - Google Chrome`, + bounds: chromeCapture.bounds, + }; + expect(findAccessibleWindow([window], chromeCapture)).toBe(window); + }); + + it.each([ + { platform: "linux" as const, bundleId: "com.google.Chrome" }, + { platform: "win32" as const, bundleId: "com.google.Chrome" }, + { platform: "darwin" as const, bundleId: "com.example.Editor" }, + { platform: "darwin" as const, bundleId: undefined }, + ])("keeps strict titles outside macOS Chrome: %j", (identity) => { + const window = { + name: `${chromeCapture.title} - Google Chrome – Profile`, + bounds: chromeCapture.bounds, + }; + expect(findAccessibleWindow([window], { ...chromeCapture, ...identity })).toBeUndefined(); + }); + + it.each([ + "Different page - Google Chrome – Profile", + "Issues · pingdotgg/t3code copied - Google Chrome – Profile", + "Issues · pingdotgg/t3code - Google ChromeSomething", + ])("rejects unrelated browser titles: %s", (name) => { + expect( + findAccessibleWindow([{ name, bounds: chromeCapture.bounds }], chromeCapture), + ).toBeUndefined(); + }); + + it("keeps bounds checks and rejects ambiguous browser suffix matches", () => { + const window = { + name: `${chromeCapture.title} - Google Chrome – Profile`, + bounds: chromeCapture.bounds, + }; + expect( + findAccessibleWindow([{ ...window, bounds: { ...window.bounds, x: 3 } }], chromeCapture), + ).toBeUndefined(); + expect( + findAccessibleWindow([window, { ...window, active: true }], chromeCapture), + ).toBeUndefined(); + expect(findAccessibleWindow([window], chromeCapture, "wayland")).toBeUndefined(); + }); + + it("prefers an exact title over a browser suffix match", () => { + const exact = { name: chromeCapture.title, bounds: chromeCapture.bounds }; + const browser = { + name: `${chromeCapture.title} - Google Chrome – Profile`, + bounds: chromeCapture.bounds, + active: true, + }; + expect(findAccessibleWindow([browser, exact], chromeCapture)).toBe(exact); + }); + + it("does not strip browser suffixes from captured page titles", () => { + const window = { name: "Page - Google Chrome – Profile", bounds: chromeCapture.bounds }; + expect( + findAccessibleWindow([window], { ...chromeCapture, title: "Page - Google Chrome" }), + ).toBeUndefined(); + }); + it("matches one window by its captured bounds", () => { const windows = [ { name: "Private", bounds: { x: 0, y: 0, width: 400, height: 300 } }, diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 4664d68a387f..6fc63f2b8cdd 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -466,6 +466,8 @@ export function findAccessibleWindow< readonly sourceTitle?: string; readonly bounds: WindowBounds; readonly clientBounds?: WindowBounds | undefined; + readonly platform?: NodeJS.Platform; + readonly bundleId?: string | undefined; }, matchMode: "screen-bounds" | "wayland" = "screen-bounds", ): T | undefined { @@ -487,19 +489,43 @@ export function findAccessibleWindow< matchMode === "wayland" && captured.clientBounds ? [captured.bounds, captured.clientBounds] : [captured.bounds]; - const matches = windows.filter((window) => { + const candidates = windows.filter((window) => { const bounds = window.bounds; return ( - titles.has(normalizeTitle(window.name ?? "")) && bounds !== null && candidateBounds.some((candidate) => boundsKeys.every((key) => Math.abs(bounds[key] - candidate[key]) <= 2), ) ); }); + const matches = candidates.filter((window) => titles.has(normalizeTitle(window.name ?? ""))); if (matches.length === 1) return matches[0]; - const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); - return activeMatches.length === 1 ? activeMatches[0] : undefined; + if (matches.length > 1) { + const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); + return activeMatches.length === 1 ? activeMatches[0] : undefined; + } + + // Chrome's macOS AX title can append the browser and profile to the captured page title. + if (captured.platform !== "darwin" || matchMode !== "screen-bounds") return undefined; + const browserName = /^com\.google\.Chrome(?:\.(?:beta|dev|canary))?$/.test( + captured.bundleId ?? "", + ) + ? "Google Chrome" + : captured.bundleId === "org.chromium.Chromium" + ? "Chromium" + : undefined; + if (!browserName) return undefined; + const browserMatches = candidates.filter((window) => { + const name = window.name?.trim() ?? ""; + return [...titles].some((title) => { + const prefix = `${title} - ${browserName}`; + return ( + name === prefix || + (name.startsWith(prefix) && /^ [–—-] \S.*$/u.test(name.slice(prefix.length))) + ); + }); + }); + return browserMatches.length === 1 ? browserMatches[0] : undefined; } const ELECTRON_KEY_NAMES: Readonly> = {