From d49e4434df46edb41263ab067a38b1ace2d16642 Mon Sep 17 00:00:00 2001 From: TMHSDigital Date: Tue, 16 Jun 2026 18:58:39 -0400 Subject: [PATCH] fix: reject off-desktop region offsets with a clear error (#41) A region: whose rectangle fell outside the virtual desktop (a negative offset before the leftmost monitor, or one past the right/bottom edge) was accepted by parseTarget and then failed in gdigrab with a cryptic libav error. resolveCaptureTarget now validates a raw region against the live virtual-desktop bounds (the bounding box of all monitors) before capture. Negative offsets stay valid when a monitor sits left of/above the primary, since the check uses the real desktop origin, not a blanket >= 0. virtualDesktopBounds and validateRegionOnDesktop are pure and unit-tested. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/__tests__/targets.test.ts | 43 ++++++++++++++++++++++++++++++++++ src/utils/resolveTarget.ts | 9 ++++++- src/utils/targets.ts | 44 +++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33849bd..96cf7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ project adheres to [Semantic Versioning](https://semver.org/). - ESLint (flat config, typescript-eslint recommended) with a `lint` script, run in CI (#23). Tooling only; not part of the published package. +## [0.8.12] + +### Fixed + +- **Off-desktop region offsets are rejected with a clear error** (#41). A + `region:` whose rectangle fell outside the virtual desktop (a negative offset + before the leftmost monitor, or one running past the right/bottom edge) was + accepted by `parseTarget` and then failed in gdigrab with a cryptic libav + error. The region is now validated against the live virtual-desktop bounds (the + bounding box of all monitors) before capture. Negative offsets remain valid + when a monitor sits left of or above the primary, since the check uses the real + desktop origin rather than a blanket `>= 0`. + ## [0.8.11] ### Fixed diff --git a/package-lock.json b/package-lock.json index e6df5d1..bfa3bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tmhs/screencast-mcp", - "version": "0.8.11", + "version": "0.8.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tmhs/screencast-mcp", - "version": "0.8.11", + "version": "0.8.12", "license": "CC-BY-NC-ND-4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index 84e358b..1c30973 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tmhs/screencast-mcp", - "version": "0.8.11", + "version": "0.8.12", "description": "MCP server for Windows screen recording, frame sampling, and minimal ffmpeg edits", "type": "module", "main": "dist/index.js", diff --git a/src/__tests__/targets.test.ts b/src/__tests__/targets.test.ts index f88aa76..f9840d9 100644 --- a/src/__tests__/targets.test.ts +++ b/src/__tests__/targets.test.ts @@ -6,6 +6,8 @@ import { buildScreenshotArgs, buildAudioInputArgs, resolveMonitor, + virtualDesktopBounds, + validateRegionOnDesktop, QUALITY_PRESETS, } from "../utils/targets.js"; import type { Monitor } from "../utils/monitors.js"; @@ -102,6 +104,47 @@ describe("resolveMonitor", () => { }); }); +describe("virtualDesktopBounds", () => { + it("is the bounding box of all monitors", () => { + expect(virtualDesktopBounds(MONITORS)).toEqual({ x: 0, y: 0, w: 4480, h: 1440 }); + }); + it("returns a zero box with no monitors", () => { + expect(virtualDesktopBounds([])).toEqual({ x: 0, y: 0, w: 0, h: 0 }); + }); +}); + +describe("validateRegionOnDesktop", () => { + it("accepts a region inside the desktop", () => { + expect(() => + validateRegionOnDesktop({ x: 100, y: 50, w: 800, h: 600 }, MONITORS), + ).not.toThrow(); + }); + it("rejects a negative offset before the leftmost monitor", () => { + expect(() => + validateRegionOnDesktop({ x: -100, y: 0, w: 300, h: 200 }, MONITORS), + ).toThrow(/outside the virtual desktop/); + }); + it("rejects a rectangle running past the right edge", () => { + expect(() => + validateRegionOnDesktop({ x: 4400, y: 0, w: 200, h: 200 }, MONITORS), + ).toThrow(/outside the virtual desktop/); + }); + it("accepts a legitimate negative offset when a monitor sits left of the primary", () => { + const withLeft: Monitor[] = [ + { index: 0, x: 0, y: 0, width: 1920, height: 1080, primary: true }, + { index: 1, x: -1920, y: 0, width: 1920, height: 1080, primary: false }, + ]; + expect(() => + validateRegionOnDesktop({ x: -100, y: 0, w: 300, h: 200 }, withLeft), + ).not.toThrow(); + }); + it("skips validation when no monitor geometry is available", () => { + expect(() => + validateRegionOnDesktop({ x: -9999, y: -9999, w: 10, h: 10 }, []), + ).not.toThrow(); + }); +}); + describe("buildCaptureArgs", () => { it("captures the full desktop with gdigrab and fragmented mp4", () => { const args = buildCaptureArgs({ kind: "full" }, { output: "out.mp4" }); diff --git a/src/utils/resolveTarget.ts b/src/utils/resolveTarget.ts index 839f9a1..acfc7be 100644 --- a/src/utils/resolveTarget.ts +++ b/src/utils/resolveTarget.ts @@ -10,7 +10,7 @@ * surface for GPU-composited windows). The rect is resolved once, here. * - full / region -> passed through unchanged. */ -import { parseTarget, type Target } from "./targets.js"; +import { parseTarget, validateRegionOnDesktop, type Target } from "./targets.js"; import { getMonitors, type Monitor } from "./monitors.js"; import { resolveWindowBounds, type WindowBounds } from "./windows.js"; @@ -35,5 +35,12 @@ export function resolveCaptureTarget(spec: string): ResolvedTarget { if (parsed.kind === "monitor") { return { target: parsed, monitors: getMonitors() }; } + if (parsed.kind === "region") { + // A window resolves to a region already clamped to the desktop; a raw + // region does not, so validate it against the live desktop bounds before + // gdigrab fails cryptically on an off-desktop rectangle. + validateRegionOnDesktop(parsed, getMonitors()); + return { target: parsed, monitors: [] }; + } return { target: parsed, monitors: [] }; } diff --git a/src/utils/targets.ts b/src/utils/targets.ts index 57aba7c..debe9aa 100644 --- a/src/utils/targets.ts +++ b/src/utils/targets.ts @@ -113,6 +113,50 @@ export function resolveMonitor(index: number, monitors: Monitor[]): Monitor { return m; } +/** The virtual desktop = the bounding box of every monitor. gdigrab captures + * this rectangle, so a region must fit inside it. Returns a zero-size box when + * no monitors are supplied (the caller then skips bounds validation). */ +export function virtualDesktopBounds( + monitors: Monitor[], +): { x: number; y: number; w: number; h: number } { + if (monitors.length === 0) return { x: 0, y: 0, w: 0, h: 0 }; + const minX = Math.min(...monitors.map((m) => m.x)); + const minY = Math.min(...monitors.map((m) => m.y)); + const maxX = Math.max(...monitors.map((m) => m.x + m.width)); + const maxY = Math.max(...monitors.map((m) => m.y + m.height)); + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +/** + * Reject a region rectangle that is not fully inside the virtual desktop. + * gdigrab passes the offset/size straight through, so an off-desktop rectangle + * (a negative offset before the leftmost monitor, or one running past the + * right/bottom edge) otherwise fails with a cryptic libav error. Negative + * offsets are legitimate when a monitor sits left of / above the primary, so + * the rectangle is checked against the real desktop origin, not a blanket >= 0. + * With no monitor geometry the check is skipped (cannot validate). + */ +export function validateRegionOnDesktop( + region: { x: number; y: number; w: number; h: number }, + monitors: Monitor[], +): void { + if (monitors.length === 0) return; + const v = virtualDesktopBounds(monitors); + const within = + region.x >= v.x && + region.y >= v.y && + region.x + region.w <= v.x + v.w && + region.y + region.h <= v.y + v.h; + if (!within) { + throw new ScreencastError( + `region ${region.x},${region.y},${region.w},${region.h} is outside the ` + + `virtual desktop (origin ${v.x},${v.y}, size ${v.w}x${v.h}). gdigrab ` + + `captures the desktop, so the rectangle must fit inside it. Adjust the ` + + `offset/size, or use monitor: to capture a whole display.`, + ); + } +} + /** Encoder args for a quality preset (libx264, web-safe pixel format). */ export function resolveQuality(quality: Quality): string[] { const q = QUALITY_PRESETS[quality];