diff --git a/electron/mcp/media-tools.ts b/electron/mcp/media-tools.ts index f04901978..5c53b51cb 100644 --- a/electron/mcp/media-tools.ts +++ b/electron/mcp/media-tools.ts @@ -22,6 +22,7 @@ import { collectAutoZoomSuggestionsForDocument, } from "../../src/lib/ai-edition/timeline/apply-auto-zooms"; import type { CliDoneResult, CliRequest } from "../../src/lib/cliContracts"; +import { HIDDEN_WINDOWS_NOTE } from "../../src/lib/cliSourcePick"; import type { CursorRecordingData, CursorRecordingSample } from "../../src/native/contracts"; import { runDocumentTool } from "../ai-edition/deep-agent/service"; import type { DocumentService } from "../ai-edition/document-service"; @@ -228,8 +229,7 @@ export function registerMediaTools(server: McpServer, deps: MediaToolDeps): void server.registerTool( "listSources", { - description: - "List the displays and windows OpenScreen can record, and the microphones. Pass a display's index or part of a window's title to startRecording.", + description: `List the displays and windows OpenScreen can record, and the microphones. Pass a display's index or part of a window's title to startRecording. ${HIDDEN_WINDOWS_NOTE}`, inputSchema: z.object({}), annotations: READ_ONLY, }, diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index d585d855d..d6cc51936 100644 --- a/src/cli/CliRecordRunner.tsx +++ b/src/cli/CliRecordRunner.tsx @@ -9,6 +9,7 @@ import { } from "@/components/video-editor/projectPersistence"; import { useScreenRecorder } from "@/hooks/useScreenRecorder"; import type { CliRecordRequest } from "@/lib/cliContracts"; +import { pickRecordingSource } from "@/lib/cliSourcePick"; import { portalOwnsSourceSelection } from "@/lib/nativeLinuxRecording"; import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; @@ -20,31 +21,7 @@ async function pickSource(request: CliRecordRequest): Promise source.id.startsWith("window:") && source.name.toLowerCase().includes(needle), - ); - if (!match) { - const windows = sources - .filter((s) => s.id.startsWith("window:")) - .map((s) => ` - ${s.name}`) - .join("\n"); - throw new Error( - `No window title contains "${request.windowTitle}". Open windows:\n${windows}`, - ); - } - return match; - } - - const screens = sources.filter((source) => source.id.startsWith("screen:")); - const screen = screens[request.displayIndex]; - if (!screen) { - throw new Error( - `Display index ${request.displayIndex} not found (${screens.length} screen(s) available)`, - ); - } - return screen; + return pickRecordingSource(sources, request); } async function resolveMicDeviceId(deviceNameFilter: string | null): Promise<{ diff --git a/src/lib/cliSourcePick.test.ts b/src/lib/cliSourcePick.test.ts new file mode 100644 index 000000000..d9ef816b7 --- /dev/null +++ b/src/lib/cliSourcePick.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { HIDDEN_WINDOWS_NOTE, pickRecordingSource } from "./cliSourcePick"; + +const sources = [ + { id: "screen:0:0", name: "Entire screen" }, + { id: "screen:1:0", name: "Screen 2" }, + { id: "window:65868:0", name: "Parsec" }, + { id: "window:656460:0", name: "Paramètres" }, +]; + +describe("pickRecordingSource", () => { + it("picks the first window whose title contains the text, ignoring case", () => { + expect(pickRecordingSource(sources, { windowTitle: "PARAM", displayIndex: 0 }).id).toBe( + "window:656460:0", + ); + }); + + it("never matches a display by its name", () => { + expect(() => pickRecordingSource(sources, { windowTitle: "screen", displayIndex: 0 })).toThrow( + /No window title contains "screen"/, + ); + }); + + it("says a missing window may be minimized or hidden, and lists the windows it saw", () => { + const miss = () => pickRecordingSource(sources, { windowTitle: "Edge", displayIndex: 0 }); + expect(miss).toThrow(HIDDEN_WINDOWS_NOTE); + expect(miss).toThrow(" - Parsec\n - Paramètres"); + }); + + it("falls back to the display at the index, counting displays only", () => { + expect(pickRecordingSource(sources, { windowTitle: null, displayIndex: 1 }).id).toBe( + "screen:1:0", + ); + expect(() => pickRecordingSource(sources, { windowTitle: null, displayIndex: 2 })).toThrow( + "Display index 2 not found (2 screen(s) available)", + ); + }); +}); diff --git a/src/lib/cliSourcePick.ts b/src/lib/cliSourcePick.ts new file mode 100644 index 000000000..51ee8a771 --- /dev/null +++ b/src/lib/cliSourcePick.ts @@ -0,0 +1,42 @@ +// Which enumerated source `openscreen record` (and the MCP's startRecording) records: the first +// window whose title contains `windowTitle`, ignoring case, or else the display at `displayIndex`. + +import type { CliRecordRequest } from "./cliContracts"; + +/** + * Chromium's window enumeration lists on-screen windows only, and the HUD's picker reads the + * same list. WebRTC's Windows list skips minimized and hidden windows; on macOS, + * ThumbnailCapturerMac asks ScreenCaptureKit for on-screen windows, so a window on another Space + * or behind a full-screen app is missing too. A window the user can see in the taskbar or the + * Dock may still be absent here, and the miss says so rather than leaving the caller to guess + * (#1140). + */ +export const HIDDEN_WINDOWS_NOTE = + "Windows that are minimized, hidden, on another desktop or Space, or behind a full-screen app are not listed: bring a window on screen to record it."; + +export function pickRecordingSource( + sources: T[], + request: Pick, +): T { + if (request.windowTitle) { + const needle = request.windowTitle.toLowerCase(); + const windows = sources.filter((source) => source.id.startsWith("window:")); + const match = windows.find((source) => source.name.toLowerCase().includes(needle)); + if (!match) { + const listed = windows.map((source) => ` - ${source.name}`).join("\n"); + throw new Error( + `No window title contains "${request.windowTitle}". ${HIDDEN_WINDOWS_NOTE} Open windows:\n${listed}`, + ); + } + return match; + } + + const screens = sources.filter((source) => source.id.startsWith("screen:")); + const screen = screens[request.displayIndex]; + if (!screen) { + throw new Error( + `Display index ${request.displayIndex} not found (${screens.length} screen(s) available)`, + ); + } + return screen; +}