diff --git a/README.md b/README.md index 1e27faed..f015bf53 100755 --- a/README.md +++ b/README.md @@ -719,7 +719,9 @@ MCP Client can access the following tools to interact with Windows: - `DisplayInventory`: Read display layout, work areas, effective DPI, and scale metadata. - `Screenshot`: Fast screenshot-first desktop capture with cursor position, active/open windows, and an image. Skips UI tree extraction for speed and should be the default first call when you mainly need visual context. Supports `display=[0]` or `display=[0,1]` using zero-based active Windows display indices. After capture, a brief orange-red glowing border is drawn inside the captured area as a visual confirmation (set `WINDOWS_MCP_DISABLE_FLASH=1` to disable). - `Snapshot`: Full desktop state capture for workflows that need interactive element ids, scrollable regions, or `use_dom=True` browser extraction. Supports `use_vision=True` for including screenshots and `display=[0]` or `display=[0,1]` using zero-based active Windows display indices. -- `App`: Launch an application by Start Menu name or strictly by executable path with separated argv and optional cwd; resize, move, and switch between windows. +- `App`: Launch an application by Start Menu name or strictly by executable path; resize or + switch windows by convenient name matching; or find, activate, and set outer/client bounds + using exact native window identity. - `PowerShell`: To execute PowerShell commands. - `FileSystem`: Read, write, copy, move, delete, list, search, and inspect files and directories. - `Scrape`: To scrape the entire webpage for information. diff --git a/manifest.json b/manifest.json index 9bb66d05..f688521e 100755 --- a/manifest.json +++ b/manifest.json @@ -78,7 +78,7 @@ "tools": [ { "name": "App", - "description": "Manages Windows applications with three modes: 'launch' (opens the prescibed application), 'resize' (adjusts the size/position of a named window or the active window if name is omitted), 'switch' (brings specific window into focus)." + "description": "Launches applications and manages windows. Supports Start Menu or exact executable launch, convenient name-based resize and switch, and exact find_window, activate_window, and set_window_bounds modes using native window identity." }, { "name": "PowerShell", diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index 320e0a1e..1ab9062e 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -15,6 +15,7 @@ from windows_mcp.tree.service import Tree from windows_mcp.desktop import screenshot as screenshot_capture from windows_mcp.desktop import flash_overlay +from windows_mcp.desktop.window import ExactWindowController from windows_mcp.infrastructure import validate_url from urllib.parse import urljoin from locale import getpreferredencoding @@ -81,6 +82,7 @@ def __init__(self): self.encoding = getpreferredencoding() self.tree = Tree(self) self.desktop_state = None + self._exact_window = ExactWindowController(self) def get_state( self, @@ -617,6 +619,61 @@ def bring_window_to_top(self, target_handle: int): except Exception as e: logger.exception(f"Failed to bring window to top: {e}") + def find_exact_windows( + self, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + process: str | None = None, + process_id: int | None = None, + handle: int | None = None, + ) -> list[dict[str, object]]: + """Find top-level windows using explicit identity filters.""" + return self._exact_window.find_exact_windows( + title=title, + title_match=title_match, + process=process, + process_id=process_id, + handle=handle, + ) + + def activate_exact_window( + self, + handle: int, + process_id: int | None = None, + process: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + ) -> dict[str, object]: + """Activate an exact window and verify foreground readback.""" + return self._exact_window.activate_exact_window( + handle, + process_id, + process, + title, + title_match, + ) + + def set_exact_window_bounds( + self, + handle: int, + outer: list[int] | None = None, + client: list[int] | None = None, + process_id: int | None = None, + process: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + ) -> dict[str, object]: + """Set an exact window's outer or client bounds.""" + return self._exact_window.set_exact_window_bounds( + handle, + outer, + client, + process_id, + process, + title, + title_match, + ) + def get_coordinates_from_label(self, label: int) -> tuple[int, int]: tree_state = self.desktop_state.tree_state if label < len(tree_state.interactive_nodes): diff --git a/src/windows_mcp/desktop/window.py b/src/windows_mcp/desktop/window.py new file mode 100644 index 00000000..cd72bf6c --- /dev/null +++ b/src/windows_mcp/desktop/window.py @@ -0,0 +1,365 @@ +"""Exact top-level window discovery, activation, and bounds control.""" + +import ctypes +from ctypes import wintypes +import logging +import os +from time import perf_counter, sleep +from typing import Any, Literal + +from psutil import Process +import win32con +import win32gui +import win32process + +import windows_mcp.uia as uia +from windows_mcp.desktop.views import Status + + +logger = logging.getLogger(__name__) + + +class ExactWindowController: + """Operate on exact native windows and reuse Desktop foreground switching.""" + + def __init__(self, desktop: Any) -> None: + self._desktop = desktop + + @staticmethod + def _window_process_name(process_id: int) -> str | None: + try: + return Process(process_id).name() + except Exception: + return None + + @staticmethod + def _window_process_path(process_id: int) -> str | None: + try: + return Process(process_id).exe() + except Exception: + return None + + @staticmethod + def _client_bounds(handle: int) -> dict[str, int]: + left, top, right, bottom = win32gui.GetClientRect(handle) + screen_left, screen_top = win32gui.ClientToScreen(handle, (left, top)) + width = right - left + height = bottom - top + return { + "left": screen_left, + "top": screen_top, + "width": width, + "height": height, + "right": screen_left + width, + "bottom": screen_top + height, + } + + @staticmethod + def _outer_bounds(handle: int) -> dict[str, int]: + left, top, right, bottom = win32gui.GetWindowRect(handle) + return { + "left": left, + "top": top, + "width": right - left, + "height": bottom - top, + "right": right, + "bottom": bottom, + } + + def _window_identity_from_handle( + self, + handle: int, + *, + title: str | None = None, + status: str | None = None, + process_id: int | None = None, + ) -> dict[str, object]: + if process_id is None: + _, process_id = win32process.GetWindowThreadProcessId(handle) + if title is None: + title = win32gui.GetWindowText(handle) + if status is None: + if uia.IsIconic(handle): + status = Status.MINIMIZED.value + elif uia.IsZoomed(handle): + status = Status.MAXIMIZED.value + elif uia.IsWindowVisible(handle): + status = Status.NORMAL.value + else: + status = Status.HIDDEN.value + return { + "handle": handle, + "process_id": process_id, + "process": self._window_process_name(process_id), + "process_path": self._window_process_path(process_id), + "title": title, + "status": status, + "outer": self._outer_bounds(handle), + "client": self._client_bounds(handle), + } + + @staticmethod + def _validate_exact_identity_values( + *, + title: str | None, + process: str | None, + process_id: int | None, + handle: int | None, + ) -> None: + if title == "": + raise ValueError("title must not be empty") + if process == "": + raise ValueError("process must not be empty") + for name, value in (("process_id", process_id), ("handle", handle)): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + ): + raise ValueError(f"{name} must be a positive integer") + + @staticmethod + def _validate_window_bounds(bounds: list[int] | None, name: str) -> list[int] | None: + if bounds is None: + return None + if not isinstance(bounds, (list, tuple)) or len(bounds) != 4: + raise ValueError(f"{name} must contain exactly 4 integers") + if any(isinstance(value, bool) or not isinstance(value, int) for value in bounds): + raise ValueError(f"{name} must contain exactly 4 integers") + parsed = list(bounds) + if parsed[2] <= 0 or parsed[3] <= 0: + raise ValueError(f"{name} width and height must be greater than zero") + return parsed + + @staticmethod + def _bounds_match_with_tolerance( + actual: dict[str, int], + expected: list[int], + *, + tolerance: int = 1, + ) -> bool: + keys = ("left", "top", "width", "height") + return all( + abs(actual[key] - expected[index]) <= tolerance for index, key in enumerate(keys) + ) + + def find_exact_windows( + self, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + process: str | None = None, + process_id: int | None = None, + handle: int | None = None, + ) -> list[dict[str, object]]: + """Find top-level windows using explicit identity filters.""" + if title_match not in {"exact", "contains"}: + raise ValueError('title_match must be "exact" or "contains"') + self._validate_exact_identity_values( + title=title, + process=process, + process_id=process_id, + handle=handle, + ) + + handles: list[int] = [handle] if handle is not None else [] + if handle is None: + win32gui.EnumWindows(lambda candidate, _: handles.append(candidate) or True, None) + matches = [] + expected_process = os.path.basename(process).casefold() if process else None + for candidate in handles: + if handle is not None and candidate != handle: + continue + if not win32gui.IsWindow(candidate): + continue + _, candidate_process_id = win32process.GetWindowThreadProcessId(candidate) + if process_id is not None and candidate_process_id != process_id: + continue + candidate_title = win32gui.GetWindowText(candidate) + if title is not None: + actual_title = candidate_title.casefold() + expected_title = title.casefold() + if title_match == "exact" and actual_title != expected_title: + continue + if title_match == "contains" and expected_title not in actual_title: + continue + if expected_process is not None: + process_name = self._window_process_name(candidate_process_id) + if process_name is None or process_name.casefold() != expected_process: + continue + matches.append( + self._window_identity_from_handle( + candidate, + title=candidate_title, + process_id=candidate_process_id, + ) + ) + return matches + + def _require_exact_window( + self, + handle: int, + process_id: int | None = None, + process: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + ) -> dict[str, object]: + self._validate_exact_identity_values( + title=title, + process=process, + process_id=process_id, + handle=handle, + ) + if not win32gui.IsWindow(handle): + raise ValueError("Invalid or stale window handle") + matches = self.find_exact_windows( + title=title, + title_match=title_match, + process=process, + process_id=process_id, + handle=handle, + ) + if not matches: + raise ValueError("No window matched the supplied identity") + if len(matches) > 1: + raise ValueError("Window identity was ambiguous") + return matches[0] + + def activate_exact_window( + self, + handle: int, + process_id: int | None = None, + process: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + ) -> dict[str, object]: + """Activate an exact window and verify foreground readback.""" + self._require_exact_window(handle, process_id, process, title, title_match) + if win32gui.GetForegroundWindow() != handle: + self._desktop.bring_window_to_top(handle) + foreground_handle = win32gui.GetForegroundWindow() + if foreground_handle != handle: + raise ValueError( + f"Failed to activate exact window: foreground handle is {foreground_handle}" + ) + return self._require_exact_window(handle, process_id, process, title, title_match) + + def set_exact_window_bounds( + self, + handle: int, + outer: list[int] | None = None, + client: list[int] | None = None, + process_id: int | None = None, + process: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + ) -> dict[str, object]: + """Set outer or client bounds and return the actual applied geometry.""" + outer = self._validate_window_bounds(outer, "outer") + client = self._validate_window_bounds(client, "client") + if (outer is None) == (client is None): + raise ValueError("Provide exactly one of outer or client bounds") + self._require_exact_window(handle, process_id, process, title, title_match) + + if outer is not None: + x, y, width, height = outer + else: + x, y, width, height = self._outer_bounds_for_client(handle, client) + + win32gui.MoveWindow(handle, x, y, width, height, True) + return self._wait_for_exact_window_bounds( + handle=handle, + process_id=process_id, + process=process, + title=title, + title_match=title_match, + outer=outer, + client=client, + ) + + def _outer_bounds_for_client( + self, + handle: int, + client: list[int], + ) -> tuple[int, int, int, int]: + client_x, client_y, client_width, client_height = client + try: + style = win32gui.GetWindowLong(handle, win32con.GWL_STYLE) + ex_style = win32gui.GetWindowLong(handle, win32con.GWL_EXSTYLE) + rect = wintypes.RECT(0, 0, client_width, client_height) + has_menu = bool(win32gui.GetMenu(handle)) + dpi = ctypes.windll.user32.GetDpiForWindow(handle) + adjust_for_dpi = getattr(ctypes.windll.user32, "AdjustWindowRectExForDpi", None) + if adjust_for_dpi is not None: + ok = adjust_for_dpi( + ctypes.byref(rect), + style, + has_menu, + ex_style, + dpi, + ) + else: + ok = ctypes.windll.user32.AdjustWindowRectEx( + ctypes.byref(rect), + style, + has_menu, + ex_style, + ) + if not ok: + raise OSError("AdjustWindowRectEx failed") + border_left = -rect.left + border_top = -rect.top + outer_width = rect.right - rect.left + outer_height = rect.bottom - rect.top + return client_x - border_left, client_y - border_top, outer_width, outer_height + except Exception as exc: + logger.debug("Falling back to current window frame deltas: %s", exc) + current_outer = self._outer_bounds(handle) + current_client = self._client_bounds(handle) + return ( + client_x - (current_client["left"] - current_outer["left"]), + client_y - (current_client["top"] - current_outer["top"]), + client_width + (current_outer["width"] - current_client["width"]), + client_height + (current_outer["height"] - current_client["height"]), + ) + + def _wait_for_exact_window_bounds( + self, + *, + handle: int, + process_id: int | None, + process: str | None, + title: str | None, + title_match: Literal["exact", "contains"], + outer: list[int] | None, + client: list[int] | None, + timeout: float = 2.0, + ) -> dict[str, object]: + deadline = perf_counter() + timeout + last_identity = self._require_exact_window(handle, process_id, process, title, title_match) + while True: + target = outer or client + bounds_type = "outer" if outer is not None else "client" + if self._bounds_match_with_tolerance(last_identity[bounds_type], target): + return last_identity + if perf_counter() >= deadline: + raise TimeoutError( + f"Window {bounds_type} bounds did not reach requested target: " + f"expected {target}, actual {last_identity[bounds_type]}" + ) + if client is not None: + actual_client = last_identity["client"] + actual_outer = last_identity["outer"] + win32gui.MoveWindow( + handle, + actual_outer["left"] + client[0] - actual_client["left"], + actual_outer["top"] + client[1] - actual_client["top"], + actual_outer["width"] + client[2] - actual_client["width"], + actual_outer["height"] + client[3] - actual_client["height"], + True, + ) + sleep(0.05) + last_identity = self._require_exact_window( + handle, + process_id, + process, + title, + title_match, + ) diff --git a/src/windows_mcp/tools/app.py b/src/windows_mcp/tools/app.py index d0ef28a0..6de664ce 100644 --- a/src/windows_mcp/tools/app.py +++ b/src/windows_mcp/tools/app.py @@ -1,4 +1,4 @@ -"""App tool — launch, resize, switch applications.""" +"""App tool — launch applications and manage convenience or exact windows.""" import json import subprocess @@ -10,6 +10,19 @@ from fastmcp import Context +AppMode = Literal[ + "launch", + "launch_executable", + "resize", + "switch", + "find_window", + "activate_window", + "set_window_bounds", +] +EXACT_WINDOW_MODES = {"find_window", "activate_window", "set_window_bounds"} +ALL_APP_MODES = {"launch", "launch_executable", "resize", "switch", *EXACT_WINDOW_MODES} + + def _as_args(value: list[str] | str | None) -> list[str]: if value is None: return [] @@ -22,6 +35,72 @@ def _as_args(value: list[str] | str | None) -> list[str]: return args +def _as_strict_int(value: object, name: str) -> int: + if isinstance(value, bool): + raise ValueError(f"{name} must contain integers, not booleans") + if isinstance(value, int): + return value + if isinstance(value, str): + stripped = value.strip() + if stripped and stripped.lstrip("+-").isdigit(): + return int(stripped) + raise ValueError(f"{name} must contain exactly 4 integers") + + +def _as_optional_positive_int(value: object, name: str) -> int | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"{name} must be a positive integer") + if isinstance(value, int): + parsed = value + elif isinstance(value, str): + stripped = value.strip() + if not stripped or not stripped.lstrip("+").isdigit(): + raise ValueError(f"{name} must be a positive integer") + parsed = int(stripped) + else: + raise ValueError(f"{name} must be a positive integer") + if parsed <= 0: + raise ValueError(f"{name} must be a positive integer") + return parsed + + +def _as_bounds(value: list[int] | str | None, name: str) -> list[int] | None: + if value is None or isinstance(value, list): + bounds = value + elif isinstance(value, str): + try: + bounds = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError( + f"{name} must be a list of exactly 4 integers [x, y, width, height]" + ) from exc + else: + raise ValueError(f"{name} must be a list of exactly 4 integers [x, y, width, height]") + if bounds is None: + return None + if not isinstance(bounds, list) or len(bounds) != 4: + raise ValueError(f"{name} must be a list of exactly 4 integers [x, y, width, height]") + parsed = [_as_strict_int(item, name) for item in bounds] + if parsed[2] <= 0 or parsed[3] <= 0: + raise ValueError(f"{name} width and height must be greater than zero") + return parsed + + +def _validate_exact_window_text( + title: str | None, + title_match: str, + process: str | None, +) -> None: + if title_match not in {"exact", "contains"}: + raise ValueError('title_match must be "exact" or "contains"') + if title == "": + raise ValueError("title must not be empty") + if process == "": + raise ValueError("process must not be empty") + + def _resolve_executable(executable: str) -> Path: path = Path(executable).expanduser().resolve() if not path.is_file(): @@ -72,10 +151,10 @@ def register(mcp, *, get_desktop, get_analytics): name="App", description=( "Open/start/launch applications and manage windows. Keywords: open, start, launch, program, " - "application, window, foreground, focus, resize. Four modes: 'launch' (opens an application " - "by Start Menu name), 'launch_executable' (strictly launches one executable path with separated " - "argv and optional cwd), 'resize' (adjusts a named or active window), and 'switch' (brings a " - "specific window into focus)." + "application, window, foreground, focus, resize. Modes: 'launch' and 'launch_executable' " + "start applications; 'resize' and 'switch' provide convenient name-based window control; " + "'find_window', 'activate_window', and 'set_window_bounds' use exact native window identity " + "without fuzzy matching." ), annotations=ToolAnnotations( title="App", @@ -87,19 +166,41 @@ def register(mcp, *, get_desktop, get_analytics): ) @with_analytics(get_analytics(), "App-Tool") def app_tool( - mode: Literal["launch", "launch_executable", "resize", "switch"] = "launch", + mode: AppMode = "launch", name: str | None = None, window_loc: list[int] | None = None, window_size: list[int] | None = None, executable: str | None = None, args: list[str] | str | None = None, cwd: str | None = None, + title: str | None = None, + title_match: Literal["exact", "contains"] = "contains", + process: str | None = None, + process_id: int | str | None = None, + handle: int | str | None = None, + outer: list[int] | str | None = None, + client: list[int] | str | None = None, ctx: Context = None, - ): + ) -> str | dict[str, object]: + if mode not in ALL_APP_MODES: + raise ValueError( + "mode must be one of: launch, launch_executable, resize, switch, " + "find_window, activate_window, set_window_bounds" + ) + exact_launch_inputs = (executable, args, cwd) if mode != "launch_executable" and any(value is not None for value in exact_launch_inputs): raise ValueError('executable, args, and cwd require mode="launch_executable"') + exact_window_inputs = (title, process, process_id, handle, outer, client) + if mode not in EXACT_WINDOW_MODES and ( + any(value is not None for value in exact_window_inputs) or title_match != "contains" + ): + raise ValueError( + "title, title_match, process, process_id, handle, outer, and client require an " + "exact window mode" + ) + if mode == "launch_executable": if executable is None: raise ValueError('executable is required for mode="launch_executable"') @@ -110,4 +211,60 @@ def app_tool( ) return _launch_executable(executable, args, cwd) + if mode in EXACT_WINDOW_MODES: + if name is not None or window_loc is not None or window_size is not None: + raise ValueError( + "name, window_loc, and window_size are not supported for exact window modes" + ) + + _validate_exact_window_text(title, title_match, process) + process_id = _as_optional_positive_int(process_id, "process_id") + handle = _as_optional_positive_int(handle, "handle") + outer = _as_bounds(outer, "outer") + client = _as_bounds(client, "client") + + if mode == "find_window": + if outer is not None or client is not None: + raise ValueError( + "outer and client are only supported for mode='set_window_bounds'" + ) + windows = get_desktop().find_exact_windows( + title=title, + title_match=title_match, + process=process, + process_id=process_id, + handle=handle, + ) + return {"windows": windows, "count": len(windows)} + + if handle is None: + raise ValueError(f"handle is required for mode='{mode}'") + + if mode == "activate_window": + if outer is not None or client is not None: + raise ValueError( + "outer and client are only supported for mode='set_window_bounds'" + ) + window = get_desktop().activate_exact_window( + handle=handle, + process_id=process_id, + process=process, + title=title, + title_match=title_match, + ) + return {"activated": window} + + if (outer is None) == (client is None): + raise ValueError("mode='set_window_bounds' requires exactly one of outer or client") + window = get_desktop().set_exact_window_bounds( + handle=handle, + outer=outer, + client=client, + process_id=process_id, + process=process, + title=title, + title_match=title_match, + ) + return {"window": window} + return get_desktop().app(mode, name, window_loc, window_size) diff --git a/tests/test_app_exact_window.py b/tests/test_app_exact_window.py new file mode 100644 index 00000000..aa2c1c67 --- /dev/null +++ b/tests/test_app_exact_window.py @@ -0,0 +1,220 @@ +import asyncio +from collections.abc import Callable + +import pytest + +from windows_mcp.tools import app as app_module + + +class FakeMCP: + def __init__(self) -> None: + self.tools: dict[str, Callable] = {} + self.tool_options: dict[str, dict[str, object]] = {} + + def tool(self, *, name: str, **kwargs: object) -> Callable: + self.tool_options[name] = kwargs + + def decorator(func: Callable) -> Callable: + self.tools[name] = func + return func + + return decorator + + +class FakeDesktop: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + + def find_exact_windows(self, **kwargs: object) -> list[dict[str, object]]: + self.calls.append(("find", kwargs)) + return [{"handle": 100, "title": "Target App"}] + + def activate_exact_window(self, **kwargs: object) -> dict[str, object]: + self.calls.append(("activate", kwargs)) + return {"handle": kwargs["handle"], "title": "Target App"} + + def set_exact_window_bounds(self, **kwargs: object) -> dict[str, object]: + self.calls.append(("bounds", kwargs)) + return {"handle": kwargs["handle"], "outer": kwargs["outer"]} + + +def _register(get_desktop: Callable[[], object]) -> tuple[Callable, FakeMCP]: + mcp = FakeMCP() + app_module.register(mcp, get_desktop=get_desktop, get_analytics=lambda: None) + return mcp.tools["App"], mcp + + +def test_app_annotations_cover_mutating_exact_modes() -> None: + _, mcp = _register(FakeDesktop) + + assert set(mcp.tools) == {"App"} + annotations = mcp.tool_options["App"]["annotations"] + assert annotations.readOnlyHint is False + assert annotations.destructiveHint is True + assert annotations.idempotentHint is False + assert annotations.openWorldHint is False + + +def test_find_window_returns_native_structure_and_parses_identity() -> None: + desktop = FakeDesktop() + tool, _ = _register(lambda: desktop) + + result = asyncio.run( + tool( + mode="find_window", + title="Target", + title_match="exact", + process="app.exe", + process_id="200", + handle="100", + ) + ) + + assert result == { + "windows": [{"handle": 100, "title": "Target App"}], + "count": 1, + } + assert desktop.calls == [ + ( + "find", + { + "title": "Target", + "title_match": "exact", + "process": "app.exe", + "process_id": 200, + "handle": 100, + }, + ) + ] + + +def test_activate_window_requires_handle_before_desktop_access() -> None: + tool, _ = _register(lambda: pytest.fail("desktop must not be accessed")) + + with pytest.raises(ValueError, match="handle is required"): + asyncio.run(tool(mode="activate_window")) + + +def test_activate_window_delegates_assertions() -> None: + desktop = FakeDesktop() + tool, _ = _register(lambda: desktop) + + result = asyncio.run( + tool( + mode="activate_window", + handle=100, + title="Target", + process_id=200, + ) + ) + + assert result == {"activated": {"handle": 100, "title": "Target App"}} + assert desktop.calls[0] == ( + "activate", + { + "handle": 100, + "process_id": 200, + "process": None, + "title": "Target", + "title_match": "contains", + }, + ) + + +@pytest.mark.parametrize( + ("argument", "value", "expected"), + [ + ("outer", "[10, 20, 300, 200]", [10, 20, 300, 200]), + ("client", [30, 40, 320, 180], [30, 40, 320, 180]), + ], +) +def test_set_window_bounds_accepts_json_or_native_list( + argument: str, + value: object, + expected: list[int], +) -> None: + desktop = FakeDesktop() + tool, _ = _register(lambda: desktop) + + result = asyncio.run(tool(mode="set_window_bounds", handle=100, **{argument: value})) + + assert result["window"]["handle"] == 100 + call = desktop.calls[0] + assert call[0] == "bounds" + assert call[1][argument] == expected + + +@pytest.mark.parametrize( + "kwargs", + [ + {"mode": "unknown"}, + {"mode": "find_window", "title_match": "prefix"}, + {"mode": "find_window", "title": ""}, + {"mode": "find_window", "process": ""}, + {"mode": "find_window", "handle": True}, + {"mode": "find_window", "process_id": 0}, + {"mode": "find_window", "outer": [0, 0, 100, 100]}, + {"mode": "activate_window", "handle": 100, "client": [0, 0, 100, 100]}, + {"mode": "set_window_bounds", "handle": 100}, + { + "mode": "set_window_bounds", + "handle": 100, + "outer": [0, 0, 100, 100], + "client": [0, 0, 100, 100], + }, + {"mode": "set_window_bounds", "handle": 100, "outer": 123}, + {"mode": "set_window_bounds", "handle": 100, "outer": "not-json"}, + {"mode": "set_window_bounds", "handle": 100, "outer": [0, 0, 0, 100]}, + {"mode": "set_window_bounds", "handle": 100, "outer": [0, 0, True, 100]}, + ], +) +def test_exact_modes_reject_invalid_options_before_desktop_access( + kwargs: dict[str, object], +) -> None: + tool, _ = _register(lambda: pytest.fail("desktop must not be accessed")) + + with pytest.raises(ValueError): + asyncio.run(tool(**kwargs)) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"mode": "launch", "handle": 100}, + {"mode": "resize", "title": "Target"}, + {"mode": "switch", "process_id": 200}, + {"mode": "launch", "title_match": "exact"}, + ], +) +def test_exact_arguments_are_rejected_for_legacy_modes( + kwargs: dict[str, object], +) -> None: + tool, _ = _register(lambda: pytest.fail("desktop must not be accessed")) + + with pytest.raises(ValueError, match="require an exact window mode"): + asyncio.run(tool(**kwargs)) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"mode": "find_window", "name": "Target"}, + {"mode": "activate_window", "handle": 100, "window_loc": [0, 0]}, + { + "mode": "set_window_bounds", + "handle": 100, + "outer": [0, 0, 100, 100], + "window_size": [100, 100], + }, + {"mode": "find_window", "executable": r"C:\\App.exe"}, + {"mode": "activate_window", "handle": 100, "args": ["--flag"]}, + {"mode": "find_window", "cwd": r"C:\\work"}, + ], +) +def test_legacy_and_launch_arguments_are_rejected_for_exact_modes( + kwargs: dict[str, object], +) -> None: + tool, _ = _register(lambda: pytest.fail("desktop must not be accessed")) + + with pytest.raises(ValueError): + asyncio.run(tool(**kwargs)) diff --git a/tests/test_exact_window_controller.py b/tests/test_exact_window_controller.py new file mode 100644 index 00000000..c91f4b63 --- /dev/null +++ b/tests/test_exact_window_controller.py @@ -0,0 +1,419 @@ +import ctypes +from types import SimpleNamespace + +import pytest + +from windows_mcp.desktop import window as window_module +from windows_mcp.desktop.service import Desktop +from windows_mcp.desktop.window import ExactWindowController + + +class FakeDesktop: + def __init__(self) -> None: + self.activations: list[int] = [] + + def bring_window_to_top(self, handle: int) -> None: + self.activations.append(handle) + + +def _patch_window_api( + monkeypatch: pytest.MonkeyPatch, + *, + foreground: int = 100, +) -> list[tuple[int, int, int, int, int, bool]]: + moves: list[tuple[int, int, int, int, int, bool]] = [] + outer_rect = {"value": (10, 20, 210, 170)} + client_rect = {"value": (0, 0, 180, 120)} + client_origin = {"value": (20, 50)} + monkeypatch.setattr( + window_module.win32gui, + "EnumWindows", + lambda callback, context: callback(100, context), + ) + monkeypatch.setattr(window_module.win32gui, "IsWindow", lambda handle: handle == 100) + monkeypatch.setattr(window_module.win32gui, "GetWindowText", lambda handle: "Target App") + monkeypatch.setattr(window_module.uia, "IsIconic", lambda handle: False) + monkeypatch.setattr(window_module.uia, "IsZoomed", lambda handle: False) + monkeypatch.setattr(window_module.uia, "IsWindowVisible", lambda handle: True) + monkeypatch.setattr( + window_module.win32process, + "GetWindowThreadProcessId", + lambda handle: (300, 200), + ) + monkeypatch.setattr( + window_module.win32gui, + "GetWindowRect", + lambda handle: outer_rect["value"], + ) + monkeypatch.setattr( + window_module.win32gui, + "GetClientRect", + lambda handle: client_rect["value"], + ) + monkeypatch.setattr( + window_module.win32gui, + "ClientToScreen", + lambda handle, point: client_origin["value"], + ) + monkeypatch.setattr( + window_module.win32gui, + "GetForegroundWindow", + lambda: foreground, + ) + + def move_window( + handle: int, + x: int, + y: int, + width: int, + height: int, + repaint: bool, + ) -> None: + moves.append((handle, x, y, width, height, repaint)) + outer_rect["value"] = (x, y, x + width, y + height) + client_origin["value"] = (x + 10, y + 30) + client_rect["value"] = (0, 0, width - 20, height - 30) + + monkeypatch.setattr(window_module.win32gui, "MoveWindow", move_window) + monkeypatch.setattr( + window_module, + "Process", + lambda pid: type( + "P", + (), + { + "name": lambda self: "app.exe", + "exe": lambda self: r"C:\Apps\app.exe", + }, + )(), + ) + return moves + + +def test_find_exact_windows_filters_by_process_and_title( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + _patch_window_api(monkeypatch) + + result = controller.find_exact_windows(title="target", process="APP.EXE") + + assert result == [ + { + "handle": 100, + "process_id": 200, + "process": "app.exe", + "process_path": r"C:\Apps\app.exe", + "title": "Target App", + "status": "Normal", + "outer": { + "left": 10, + "top": 20, + "width": 200, + "height": 150, + "right": 210, + "bottom": 170, + }, + "client": { + "left": 20, + "top": 50, + "width": 180, + "height": 120, + "right": 200, + "bottom": 170, + }, + } + ] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"title": ""}, "title must not be empty"), + ({"process": ""}, "process must not be empty"), + ({"handle": True}, "handle must be a positive integer"), + ({"process_id": 0}, "process_id must be a positive integer"), + ({"title_match": "prefix"}, 'title_match must be "exact" or "contains"'), + ], +) +def test_find_rejects_invalid_identity_before_enumeration( + monkeypatch: pytest.MonkeyPatch, + kwargs: dict[str, object], + message: str, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + monkeypatch.setattr( + window_module.win32gui, + "EnumWindows", + lambda *args: pytest.fail("windows must not be enumerated"), + ) + + with pytest.raises(ValueError, match=message): + controller.find_exact_windows(**kwargs) + + +def test_find_by_handle_does_not_depend_on_enumeration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = ExactWindowController(FakeDesktop()) + _patch_window_api(monkeypatch) + monkeypatch.setattr( + window_module.win32gui, + "EnumWindows", + lambda *args: pytest.fail("windows must not be enumerated"), + ) + + result = controller.find_exact_windows(handle=100) + + assert result[0]["handle"] == 100 + + +def test_activate_verifies_foreground_and_rereads_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + _patch_window_api(monkeypatch, foreground=999) + foreground_reads = iter([999, 100]) + monkeypatch.setattr( + window_module.win32gui, + "GetForegroundWindow", + foreground_reads.__next__, + ) + reads: list[int] = [] + original_is_window = window_module.win32gui.IsWindow + monkeypatch.setattr( + window_module.win32gui, + "IsWindow", + lambda handle: reads.append(handle) or original_is_window(handle), + ) + + result = controller.activate_exact_window(handle=100, process_id=200) + + assert result["handle"] == 100 + assert desktop.activations == [100] + assert reads.count(100) >= 2 + + +def test_activate_already_foreground_skips_redundant_switch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + _patch_window_api(monkeypatch, foreground=100) + + result = controller.activate_exact_window(handle=100, process_id=200) + + assert result["handle"] == 100 + assert desktop.activations == [] + + +def test_activate_fails_when_readback_differs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + _patch_window_api(monkeypatch, foreground=999) + + with pytest.raises(ValueError, match="Failed to activate"): + controller.activate_exact_window(handle=100, process_id=200) + + +def test_set_client_bounds_converts_and_verifies_actual_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + moves = _patch_window_api(monkeypatch) + monkeypatch.setattr( + controller, + "_outer_bounds_for_client", + lambda handle, client: (20, 40, 320, 230), + ) + + result = controller.set_exact_window_bounds( + handle=100, + process_id=200, + client=[30, 70, 300, 200], + ) + + assert moves == [(100, 20, 40, 320, 230, True)] + assert result["client"] == { + "left": 30, + "top": 70, + "width": 300, + "height": 200, + "right": 330, + "bottom": 270, + } + + +def test_set_client_bounds_corrects_native_frame_prediction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + moves = _patch_window_api(monkeypatch) + monkeypatch.setattr( + controller, + "_outer_bounds_for_client", + lambda handle, client: (23, 48, 316, 220), + ) + + result = controller.set_exact_window_bounds( + handle=100, + client=[30, 70, 300, 200], + ) + + assert moves == [ + (100, 23, 48, 316, 220, True), + (100, 20, 40, 320, 230, True), + ] + assert result["client"]["left"] == 30 + assert result["client"]["top"] == 70 + assert result["client"]["width"] == 300 + assert result["client"]["height"] == 200 + + +def test_set_bounds_reports_actual_geometry_on_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + desktop = FakeDesktop() + controller = ExactWindowController(desktop) + _patch_window_api(monkeypatch) + monkeypatch.setattr(window_module.win32gui, "MoveWindow", lambda *args: None) + monkeypatch.setattr(window_module, "perf_counter", iter([0.0, 3.0]).__next__) + + with pytest.raises( + TimeoutError, + match=r"expected \[30, 40, 300, 200\].*actual.*left.*10", + ): + controller.set_exact_window_bounds( + handle=100, + process_id=200, + outer=[30, 40, 300, 200], + ) + + +def test_client_adjustment_accounts_for_native_menu( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = ExactWindowController(FakeDesktop()) + menu_flags: list[bool] = [] + monkeypatch.setattr(window_module.win32gui, "GetWindowLong", lambda *args: 0) + monkeypatch.setattr(window_module.win32gui, "GetMenu", lambda handle: 1) + + def adjust_window_rect( + rect_pointer: object, + style: int, + has_menu: bool, + ex_style: int, + dpi: int, + ) -> int: + menu_flags.append(bool(has_menu)) + rect = ctypes.cast( + rect_pointer, + ctypes.POINTER(window_module.wintypes.RECT), + ).contents + rect.left = -10 + rect.top = -40 + rect.right = 310 + rect.bottom = 230 + return 1 + + user32 = SimpleNamespace( + GetDpiForWindow=lambda handle: 144, + AdjustWindowRectExForDpi=adjust_window_rect, + ) + monkeypatch.setattr(window_module.ctypes.windll, "user32", user32) + + result = controller._outer_bounds_for_client(100, [30, 70, 300, 190]) + + assert result == (20, 30, 320, 270) + assert menu_flags == [True] + + +def test_client_adjustment_falls_back_to_current_frame_deltas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = ExactWindowController(FakeDesktop()) + monkeypatch.setattr( + window_module.win32gui, + "GetWindowLong", + lambda *args: (_ for _ in ()).throw(OSError("unsupported")), + ) + monkeypatch.setattr( + controller, + "_outer_bounds", + lambda handle: {"left": 10, "top": 20, "width": 200, "height": 150}, + ) + monkeypatch.setattr( + controller, + "_client_bounds", + lambda handle: {"left": 20, "top": 50, "width": 180, "height": 120}, + ) + + result = controller._outer_bounds_for_client(100, [30, 70, 300, 190]) + + assert result == (20, 40, 320, 220) + + +@pytest.mark.parametrize( + ("outer", "client"), + [(None, None), ([0, 0, 100, 100], [0, 0, 100, 100])], +) +def test_set_bounds_requires_exactly_one_target( + outer: list[int] | None, + client: list[int] | None, +) -> None: + controller = ExactWindowController(FakeDesktop()) + + with pytest.raises(ValueError, match="exactly one"): + controller.set_exact_window_bounds(handle=100, outer=outer, client=client) + + +def test_desktop_delegates_exact_window_operations() -> None: + calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + controller = SimpleNamespace( + find_exact_windows=lambda **kwargs: calls.append(("find", (), kwargs)) or ["found"], + activate_exact_window=lambda *args: calls.append(("activate", args, {})) or "active", + set_exact_window_bounds=lambda *args: calls.append(("bounds", args, {})) or "moved", + ) + desktop = Desktop.__new__(Desktop) + desktop._exact_window = controller + + assert desktop.find_exact_windows(title="Target") == ["found"] + assert desktop.activate_exact_window(100, 200, "app.exe", "Target", "exact") == "active" + assert ( + desktop.set_exact_window_bounds( + 100, + [10, 20, 300, 200], + None, + 200, + "app.exe", + "Target", + "exact", + ) + == "moved" + ) + assert calls == [ + ( + "find", + (), + { + "title": "Target", + "title_match": "contains", + "process": None, + "process_id": None, + "handle": None, + }, + ), + ("activate", (100, 200, "app.exe", "Target", "exact"), {}), + ( + "bounds", + (100, [10, 20, 300, 200], None, 200, "app.exe", "Target", "exact"), + {}, + ), + ]