diff --git a/CLAUDE.md b/CLAUDE.md index f4986ab..7768f79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Windows-MCP is a Python MCP (Model Context Protocol) server that bridges AI LLM agents with the Windows OS, enabling direct desktop automation. It exposes 15 tools (App, Shell, Snapshot, Click, Type, Scroll, Move, Shortcut, Wait, Scrape, MultiSelect, MultiEdit, Clipboard, Process, Notification) via FastMCP. +Windows-MCP is a Python MCP (Model Context Protocol) server that bridges AI LLM agents with the Windows OS, enabling direct desktop automation. It exposes 16 tools (App, Shell, Snapshot, Click, Type, Scroll, Move, Shortcut, Wait, Scrape, MultiSelect, MultiEdit, Clipboard, Process, Notification, LocateText) via FastMCP. ## Build & Development Commands diff --git a/manifest.json b/manifest.json index 772822f..4e3ccd2 100755 --- a/manifest.json +++ b/manifest.json @@ -131,6 +131,10 @@ { "name": "Registry", "description": "Accesses the Windows Registry. Use mode=\"get\" to read a value, mode=\"set\" to create/update a value, mode=\"delete\" to remove a value or key, mode=\"list\" to list values and sub-keys under a path." + }, + { + "name": "LocateText", + "description": "Finds center [x, y], bounding box [left, top, width, height], and id for text. Set use_vision=True to return an annotated screenshot for verification. Use region_hint (\"top\", \"bottom\", \"left\", \"right\", \"center\") to narrow search; defaults to 'all'. Returns a list of match objects. Use occurrence_index with a specific id to select a targeted result." } ], "compatibility": { diff --git a/pyproject.toml b/pyproject.toml index 266877a..0efef20 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,15 @@ dependencies = [ "tabulate>=0.9.0", "thefuzz>=0.22.1", "uuid7>=0.1.0", + "winrt-runtime>=3.2.1", + "winrt-windows-foundation>=3.2.1", + "winrt-windows-foundation-collections>=3.2.1", + "winrt-windows-globalization>=3.2.1", + "winrt-windows-graphics-imaging>=3.2.1", + "winrt-windows-media-ocr>=3.2.1", + "winrt-windows-security-cryptography>=3.2.1", + "winrt-windows-storage>=3.2.1", + "winrt-windows-storage-streams>=3.2.1", ] [project.optional-dependencies] @@ -74,3 +83,9 @@ ignore = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index 21e7582..3f20869 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -187,6 +187,7 @@ def file_system_tool( openWorldHint=False, ), ) + @with_analytics(analytics, "State-Tool") def state_tool( use_vision: bool | str = False, @@ -745,6 +746,40 @@ def registry_tool(mode: Literal['get', 'set', 'delete', 'list'], path: str, name except Exception as e: return f'Error accessing registry: {str(e)}' + +@mcp.tool( + name="LocateText", + description="Finds center bounding box for text. Keywords: text_query, use_vision, region_hint, occurrence_index; Set use_vision=True to return an annotated screenshot for verification. Use region_hint (\"top\", \"bottom\", \"left\", \"right\", \"center\") to narrow search; defaults to 'all'. Returns a list of match objects. Use occurrence_index with a specific id to select a targeted result.", + annotations=ToolAnnotations( + title="Locate Text", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), +) +@with_analytics(analytics, "Locate-Text-Tool") +async def locate_text_tool( + text_query: str, + use_vision: bool | str = False, + region_hint: Literal["all", "top", "bottom", "left", "right", "center"] = "all", + occurrence_index: int | None = None, + ctx: Context = None, +): + try: + from windows_mcp.desktop.locate_text import locate_text_tool + return await locate_text_tool( + desktop=desktop, + use_vision=use_vision, + text_query=text_query, + region_hint=region_hint, + occurrence_index=occurrence_index + ) + except Exception as e: + import traceback + logger.error(f"locate_text error: {str(e)}\n{traceback.format_exc()}") + return [f"Error in locate_text: {str(e)}"] + class Transport(Enum): STDIO = "stdio" SSE = "sse" diff --git a/src/windows_mcp/analytics.py b/src/windows_mcp/analytics.py index f59c5d5..5f7b131 100755 --- a/src/windows_mcp/analytics.py +++ b/src/windows_mcp/analytics.py @@ -104,7 +104,11 @@ async def track_error(self, error: Exception, context: Dict[str, Any]) -> None: event="exception", properties={ "exception": str(error), - "traceback": str(error) if not hasattr(error, "__traceback__") else str(error), + "traceback": ( + str(error) + if not hasattr(error, "__traceback__") + else str(error) + ), "session_id": self.mcp_interaction_id, "mode": self.mode, "process_person_profile": True, diff --git a/src/windows_mcp/desktop/locate_text.py b/src/windows_mcp/desktop/locate_text.py new file mode 100644 index 0000000..a69d7f7 --- /dev/null +++ b/src/windows_mcp/desktop/locate_text.py @@ -0,0 +1,400 @@ +import io +import json +import random +import logging +from typing import List, Dict, Any +from PIL import Image, ImageDraw, ImageFont +from fuzzywuzzy import fuzz +from textwrap import dedent +import re + +import windows_mcp.uia as uia +from fastmcp.utilities.types import Image as McpImage + +logger = logging.getLogger(__name__) + + +def clean_ocr_text(text: str) -> str: + """ + clean for CJK languages: + remove spaces between CJK characters that OCR might have inserted + """ + + cleaned = re.sub(r"(?<=[\u4e00-\u9fa5])\s+(?=[\u4e00-\u9fa5])", "", text) + return cleaned + + +def _process_image_for_transfer( + image: Image.Image, max_dimension: int = 1600, quality: int = 75 +) -> bytes: + """ + Compress the image and convert to JPEG format to reduce size + """ + + # Limit Image Transfer Size + if image.width > max_dimension or image.height > max_dimension: + image.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS) + + buffered = io.BytesIO() + # PNG -> JPEG + image.convert("RGB").save(buffered, format="JPEG", quality=quality) + img_bytes = buffered.getvalue() + buffered.close() + return img_bytes + + +async def _perform_ocr( + screenshot: Image.Image, text_query: str +) -> List[Dict[str, Any]]: + """uses Windows OCR APIs to find text in the given screenshot and return their bounding boxes. + + Args: + screenshot (Image.Image): screenshot of the desktop to perform OCR on + text_query (str): text to search for in the OCR results. + + Raises: + RuntimeError: winrt not available + RuntimeError: no OCR engine available + + Returns: + List[Dict[str, Any]]: Matched text content with bounding boxes in the format {"text": str, "rect": (x, y, w, h)} + """ + + try: + import winrt.windows.media.ocr as ocr + import winrt.windows.graphics.imaging as imaging + import winrt.windows.security.cryptography as crypto + import winrt.windows.foundation.collections as collections + import winrt.windows.globalization as globalization + except ImportError: + raise RuntimeError("winrt is missing.") + # Extract the raw RGBA matrix + rgba_image = screenshot.convert("RGBA") + + # Swap R and B Channels + r, g, b, a = rgba_image.split() + bgra_image = Image.merge("RGBA", (b, g, r, a)) + + # image -> bytes + raw_bytes = bgra_image.tobytes() + + # bytes -> IBuffer + buffer = crypto.CryptographicBuffer.create_from_byte_array(raw_bytes) + + bmp = imaging.SoftwareBitmap( + imaging.BitmapPixelFormat.BGRA8, bgra_image.width, bgra_image.height + ) + bmp.copy_from_buffer(buffer) + + languages = ocr.OcrEngine.available_recognizer_languages + target_lang = None + for lang in languages: + if "en-GB" in lang.language_tag or "en-GB" in lang.language_tag: + target_lang = lang + break + + if target_lang: + engine = ocr.OcrEngine.try_create_from_language(target_lang) + else: + engine = ocr.OcrEngine.try_create_from_user_profile_languages() + + if engine is None: + raise RuntimeError( + "No OCR engine available. Please ensure you have the appropriate OCR language packs installed in Windows settings." + ) + result = await engine.recognize_async(bmp) + + matches = [] + for line in result.lines: + raw_text = line.text + line_text = clean_ocr_text(raw_text) + # Use union over words for the boundaries of the whole line + min_x = min(w.bounding_rect.x for w in line.words) + min_y = min(w.bounding_rect.y for w in line.words) + max_r = max(w.bounding_rect.x + w.bounding_rect.width for w in line.words) + max_b = max(w.bounding_rect.y + w.bounding_rect.height for w in line.words) + + matches.append( + { + "text": line_text, + "rect": (min_x, min_y, max_r - min_x, max_b - min_y), + } + ) + + for word in line.words: + if word.text.strip(): + matches.append( + { + "text": word.text, + "rect": ( + word.bounding_rect.x, + word.bounding_rect.y, + word.bounding_rect.width, + word.bounding_rect.height, + ), + } + ) + + return matches + + +async def locate_text_tool( + desktop, + text_query: str, + use_vision: bool = False, + region_hint: str = "all", + occurrence_index: int | None = None, +): + """Analyze the match results, calculate the center position, duplicate item deletion and match the output. + + Args: + desktop (_type_): from services + text_query (str): text to search for in the OCR results. + use_vision (bool, optional): whether to return picture data. Defaults to False. + region_hint (str, optional): use for partial pruning. Defaults to "all". + occurrence_index (int | None, optional): the index of the specific occurrence to return. Defaults to None. + + Returns: + A serialized list containing match information in dictionary form and labeled images. + Each match dictionary includes the center point coordinates, bounding box dimensions, and a unique ID. + If use_vision is True, an annotated image with bounding boxes and labels for each match is also returned. + """ + + screenshot = desktop.get_screenshot() + + left_offset, top_offset, _, _ = uia.GetVirtualScreenRect() + screen_w, screen_h = screenshot.width, screenshot.height + + matches = await _perform_ocr(screenshot, text_query) + + filtered_matches = [] + text_query_lower = text_query.lower() + + # Accuracy Scoring: + def score_match(m): + return abs(len(m["text"]) - len(text_query)) + + # Closest Length Matches + matches.sort(key=score_match) + + for match in matches: + text, rect = match["text"], match["rect"] + text_lower = text.lower() + + is_exact_in = text_query_lower in text_lower + + is_fuzzy_match = fuzz.ratio(text_query_lower, text_lower) > 80 + + if not (is_exact_in or is_fuzzy_match): + continue + + if is_exact_in and len(text) > len(text_query) + 4: + continue + + rx, ry, rw, rh = rect + cx, cy = rx + rw / 2, ry + rh / 2 + + # Spatial Pruning + if region_hint == "top" and cy > screen_h / 2: + continue + if region_hint == "bottom" and cy < screen_h / 2: + continue + if region_hint == "left" and cx > screen_w / 2: + continue + if region_hint == "right" and cx < screen_w / 2: + continue + if region_hint == "center" and ( + cx < screen_w / 4 + or cx > screen_w * 3 / 4 + or cy < screen_h / 4 + or cy > screen_h * 3 / 4 + ): + continue + + bounds_dict = { + "x": int(rx + left_offset), + "y": int(ry + top_offset), + "w": int(rw), + "h": int(rh), + } + + # Collision Detection-based Duplicate Removal + is_duplicate_or_overlapped = False + for f in filtered_matches: + fb = f["bounds"] + overlap = not ( + bounds_dict["x"] > fb["x"] + fb["w"] + or bounds_dict["x"] + bounds_dict["w"] < fb["x"] + or bounds_dict["y"] > fb["y"] + fb["h"] + or bounds_dict["y"] + bounds_dict["h"] < fb["y"] + ) + if overlap: + is_duplicate_or_overlapped = True + break + + if is_duplicate_or_overlapped: + continue + + filtered_matches.append( + { + "text": text, + "center_point": {"x": int(cx + left_offset), "y": int(cy + top_offset)}, + "bounds": bounds_dict, + "rect_local": rect, + } + ) + + if not filtered_matches: + if use_vision: + screenshot_bytes = _process_image_for_transfer(screenshot) + return [ + json.dumps( + { + "status": "error", + "message": f"Text '{text_query}' not found. Please try region constraints or using a shorter query.", + "data": [], + }, + ensure_ascii=False, + indent=2, + ), + McpImage(data=screenshot_bytes, format="jpeg"), + ] + return [ + json.dumps( + { + "status": "error", + "message": f"Text '{text_query}' not found. Please try region constraints or using a shorter query.", + "data": [], + }, + ensure_ascii=False, + indent=2, + ) + ] + + # Pre-calculate candidates + candidates = [] + for i, match in enumerate(filtered_matches): + candidates.append( + { + "center_point": match["center_point"], + "bounds": match["bounds"], + "id": i + 1, + "text": match["text"], + "rect_local": match["rect_local"], + } + ) + + # Prepare Image if use_vision is True + screenshot_bytes = None + if use_vision: + padding = 5 + width = int(screenshot.width + (1.5 * padding)) + height = int(screenshot.height + (1.5 * padding)) + padded_screenshot = Image.new("RGB", (width, height), color=(255, 255, 255)) + padded_screenshot.paste(screenshot, (padding, padding)) + + draw = ImageDraw.Draw(padded_screenshot) + try: + font = ImageFont.truetype("arial.ttf", 14) + except IOError: + font = ImageFont.load_default() + + # Determine which candidates to draw + targets_to_draw = candidates + if ( + len(filtered_matches) == 1 or occurrence_index is not None + ) and occurrence_index is not None: + # If explicit index provided, only draw that one if valid + idx = occurrence_index - 1 + if 0 <= idx < len(filtered_matches): + targets_to_draw = [candidates[idx]] + + for cand in targets_to_draw: + c_id = cand["id"] + x, y, w, h = cand["rect_local"] + + # Offset applied for the padding in padded_screenshot + rx, ry = x + padding, y + padding + + # Draw bounding rect + color = "#{:06x}".format(random.randint(0, 0xFFFFFF)) + adjusted_box = (rx, ry, rx + w, ry + h) + draw.rectangle(adjusted_box, outline=color, width=2) + + # Draw label ID tag + label_text = str(c_id) + label_width = draw.textlength(label_text, font=font) + label_height = 14 + + label_x1 = rx + label_y1 = ry - label_height - 4 + label_x2 = label_x1 + label_width + 4 + label_y2 = ry + + draw.rectangle([(label_x1, label_y1), (label_x2, label_y2)], fill=color) + draw.text( + (label_x1 + 2, label_y1 + 2), + label_text, + fill=(255, 255, 255), + font=font, + ) + + screenshot_bytes = _process_image_for_transfer(padded_screenshot) + + # Return data arrays without internal dict bloat + output_candidates = [ + { + "center_point": c["center_point"], + "bounds": c["bounds"], + "id": c["id"], + } + for c in candidates + ] + + # Scenario A: Distinct Match / Explicit Index + if len(filtered_matches) == 1 or occurrence_index is not None: + idx = 0 if occurrence_index is None else occurrence_index - 1 + if idx < 0 or idx >= len(filtered_matches): + response = [ + json.dumps( + { + "status": "error", + "message": f"occurrence_index {occurrence_index} out of bounds (found {len(filtered_matches)} matches).", + "data": [], + }, + ensure_ascii=False, + indent=2, + ) + ] + if use_vision and screenshot_bytes: + response.append(McpImage(data=screenshot_bytes, format="jpeg")) + return response + + match_data = output_candidates[idx] + response = [ + json.dumps( + { + "status": "clear", + "message": "found a clear match for the query.", + "data": [match_data], + }, + ensure_ascii=False, + indent=2, + ) + ] + if use_vision and screenshot_bytes: + response.append(McpImage(data=screenshot_bytes, format="jpeg")) + return response + + # Scenario B: Disambiguation / Set-of-Mark + response_json = { + "status": "ambiguous", + "message": f"Multiple matching targets found (total: {len(output_candidates)}). Please refer to the numbered labels in the image to rerun this tool with a specified occurrence_index, or use coordinates listed in the data list below.", + "data": output_candidates, + } + + response = [json.dumps(response_json, ensure_ascii=False, indent=2)] + if use_vision and screenshot_bytes: + response.append(McpImage(data=screenshot_bytes, format="jpeg")) + + return response diff --git a/src/windows_mcp/desktop/views.py b/src/windows_mcp/desktop/views.py index 6fff359..ed2764a 100755 --- a/src/windows_mcp/desktop/views.py +++ b/src/windows_mcp/desktop/views.py @@ -81,7 +81,9 @@ def active_window_to_string(self): if not self.active_window: return "No active window found" headers = ["Name", "Depth", "Status", "Width", "Height", "Handle"] - return tabulate([self.active_window.to_row()], headers=headers, tablefmt="simple") + return tabulate( + [self.active_window.to_row()], headers=headers, tablefmt="simple" + ) def windows_to_string(self): if not self.windows: diff --git a/tests/test_locate_text.py b/tests/test_locate_text.py new file mode 100644 index 0000000..2b1c83c --- /dev/null +++ b/tests/test_locate_text.py @@ -0,0 +1,310 @@ +import pytest +import json +import base64 +from pathlib import Path +from unittest.mock import MagicMock, patch +from PIL import Image, ImageDraw + +from windows_mcp.desktop.locate_text import locate_text_tool +import windows_mcp.uia as uia + + +@pytest.fixture +def mock_desktop(): + desktop = MagicMock() + + # Create a dummy image for testing OCR (a simple white image) + img = Image.new("RGB", (800, 600), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + # Draw some "fake text" (just for visuals, OCR will be mocked) + draw.text((100, 100), "Click Here", fill=(0, 0, 0)) + draw.text((100, 400), "Click Here", fill=(0, 0, 255)) # Blue text + + desktop.get_screenshot.return_value = img + return desktop + + +@pytest.fixture +def mock_uia_screen_rect(): + with patch("windows_mcp.desktop.locate_text.uia.GetVirtualScreenRect") as mock_rect: + mock_rect.return_value = (0, 0, 1920, 1080) + yield mock_rect + + +@pytest.mark.asyncio +async def test_locate_text_no_matches(mock_desktop, mock_uia_screen_rect): + with patch("windows_mcp.desktop.locate_text._perform_ocr", return_value=[]): + result = await locate_text_tool(mock_desktop, text_query="MissingText") + assert len(result) == 1 + data = json.loads(result[0]) + assert data["status"] == "error" + assert "not found" in data["message"] + + +@pytest.mark.asyncio +async def test_locate_text_no_matches_with_vision(mock_desktop, mock_uia_screen_rect): + with patch("windows_mcp.desktop.locate_text._perform_ocr", return_value=[]): + result = await locate_text_tool( + mock_desktop, text_query="MissingText", use_vision=True + ) + assert len(result) == 2 + data = json.loads(result[0]) + assert data["status"] == "error" + assert hasattr(result[1], "data") # McpImage + + +@pytest.mark.asyncio +async def test_locate_text_single_match(mock_desktop, mock_uia_screen_rect): + mock_ocr_result = [{"text": "Submit", "rect": (50, 50, 100, 40)}] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool(mock_desktop, text_query="Submit") + assert len(result) == 1 + data = json.loads(result[0]) + assert data["status"] == "clear" + + assert data["data"][0]["center_point"]["x"] == 100 + assert data["data"][0]["center_point"]["y"] == 70 + assert data["data"][0]["bounds"]["w"] == 100 + + +@pytest.mark.asyncio +async def test_locate_text_single_match_with_vision(mock_desktop, mock_uia_screen_rect): + mock_ocr_result = [{"text": "Submit", "rect": (50, 50, 100, 40)}] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool( + mock_desktop, text_query="Submit", use_vision=True + ) + assert len(result) == 2 + data = json.loads(result[0]) + assert data["status"] == "clear" + assert data["data"][0]["center_point"]["x"] == 100 + assert hasattr(result[1], "data") # McpImage + + +@pytest.mark.asyncio +async def test_locate_text_ambiguous(mock_desktop, mock_uia_screen_rect): + mock_ocr_result = [ + {"text": "Button", "rect": (10, 10, 50, 20)}, + {"text": "Button", "rect": (10, 100, 50, 20)}, + {"text": "ButtonX", "rect": (10, 200, 50, 20)}, + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool(mock_desktop, text_query="Button") + + # Should return JSON + assert len(result) == 1 + + data = json.loads(result[0]) + assert data["status"] == "ambiguous" + assert len(data["data"]) == 3 + assert data["data"][0]["id"] == 1 + assert data["data"][1]["id"] == 2 + assert data["data"][2]["id"] == 3 + + +@pytest.mark.asyncio +async def test_locate_text_ambiguous_with_vision(mock_desktop, mock_uia_screen_rect): + mock_ocr_result = [ + {"text": "Button", "rect": (10, 10, 50, 20)}, + {"text": "Button", "rect": (10, 100, 50, 20)}, + {"text": "ButtonX", "rect": (10, 200, 50, 20)}, + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool( + mock_desktop, text_query="Button", use_vision=True + ) + assert len(result) == 2 + data = json.loads(result[0]) + assert data["status"] == "ambiguous" + assert len(data["data"]) == 3 + assert hasattr(result[1], "data") # McpImage + + +@pytest.mark.asyncio +async def test_locate_text_ambiguous_with_index(mock_desktop, mock_uia_screen_rect): + mock_ocr_result = [ + {"text": "Apply", "rect": (0, 0, 10, 10)}, + {"text": "Apply", "rect": (100, 100, 10, 10)}, + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + # When user passes occurrence_index=2, we bypass the ambiguous image branch + result = await locate_text_tool( + mock_desktop, text_query="Apply", occurrence_index=2 + ) + assert len(result) == 1 + + data = json.loads(result[0]) + assert data["status"] == "clear" + # The 2nd item has bounds x=100 + assert data["data"][0]["bounds"]["x"] == 100 + + +@pytest.mark.asyncio +async def test_locate_text_ambiguous_with_index_with_vision( + mock_desktop, mock_uia_screen_rect +): + mock_ocr_result = [ + {"text": "Apply", "rect": (0, 0, 10, 10)}, + {"text": "Apply", "rect": (100, 100, 10, 10)}, + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool( + mock_desktop, text_query="Apply", occurrence_index=2, use_vision=True + ) + assert len(result) == 2 + data = json.loads(result[0]) + assert data["status"] == "clear" + assert data["data"][0]["bounds"]["x"] == 100 + assert hasattr(result[1], "data") # McpImage + + +@pytest.mark.asyncio +async def test_locate_text_spatial_pruning(mock_desktop, mock_uia_screen_rect): + # screen in mock is 800x600 + mock_ocr_result = [ + {"text": "TopNav", "rect": (10, 10, 50, 20)}, # Cy = 20 + {"text": "TopNav", "rect": (10, 500, 50, 20)}, # Cy = 510 (bottom) + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + # We request only top half + result = await locate_text_tool( + mock_desktop, text_query="TopNav", region_hint="top" + ) + + # Since the second one is pruned, we are left with exactly 1 match -> Success! + assert len(result) == 1 + data = json.loads(result[0]) + assert data["status"] == "clear" + assert data["data"][0]["center_point"]["y"] == 20 + + +@pytest.mark.asyncio +async def test_locate_text_spatial_pruning_with_vision( + mock_desktop, mock_uia_screen_rect +): + mock_ocr_result = [ + {"text": "TopNav", "rect": (10, 10, 50, 20)}, + {"text": "TopNav", "rect": (10, 500, 50, 20)}, + ] + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool( + mock_desktop, text_query="TopNav", region_hint="top", use_vision=True + ) + assert len(result) == 2 + data = json.loads(result[0]) + assert data["status"] == "clear" + assert data["data"][0]["center_point"]["y"] == 20 + assert hasattr(result[1], "data") # McpImage + assert ( + type(result[1].data).__name__ == "bytes" + ), f"Expected type img_bytes, got {type(result[1].data).__name__}" + + +@pytest.mark.asyncio +async def test_locate_text_center_ambiguous(): + test_png = ( + Path(__file__).parent.parent / "assets" / "screenshots" / "screenshot_1.png" + ) + assert test_png.exists(), "screenshot_1.png not found" + screenshot = Image.open(test_png).convert("RGB") + width, height = screenshot.size + + desktop = MagicMock() + desktop.get_screenshot.return_value = screenshot + + # Two matches in center region + one outside center (should be pruned) + mock_ocr_result = [ + { + "text": "Windows-MCP", + "rect": (width // 2 - 220, height // 2 - 30, 170, 40), + }, + { + "text": "Windos-MCP", + "rect": (width // 2 + 30, height // 2 + 10, 180, 40), + }, + { + "text": "Windows-MCP", + "rect": (20, 20, 150, 30), + }, + ] + + with patch( + "windows_mcp.desktop.locate_text.uia.GetVirtualScreenRect", + return_value=(0, 0, width, height), + ): + with patch( + "windows_mcp.desktop.locate_text._perform_ocr", return_value=mock_ocr_result + ): + result = await locate_text_tool( + desktop, + text_query="Windows-MCP", + region_hint="center", + ) + + assert len(result) == 1 + + data = json.loads(result[0]) + assert data["status"] == "ambiguous" + assert len(data["data"]) == 2 + + candidate_ids = [c["id"] for c in data["data"]] + assert candidate_ids == [1, 2], f"Expected IDs [1, 2], but got {candidate_ids}" + + +@pytest.mark.asyncio +async def test_real_ocr_integration_with_vision(): + """ + mark for screenshot_1.png。 + """ + test_png = ( + Path(__file__).parent.parent / "assets" / "screenshots" / "screenshot_1.png" + ) + + screenshot = Image.open(test_png).convert("RGB") + width, height = screenshot.size + + desktop = MagicMock() + desktop.get_screenshot.return_value = screenshot + + with patch( + "windows_mcp.desktop.locate_text.uia.GetVirtualScreenRect", + return_value=(0, 0, width, height), + ): + result = await locate_text_tool( + desktop, + use_vision=True, + text_query="Claude", + region_hint="all", + ) + + data = json.loads(result[0]) + if data["status"] == "ambiguous": + assert len(data["data"]) == 3, "Expected 3 matches for 'Claude'" + + elif data["status"] == "clear": + pytest.fail( + f"expected ambiguous due to multiple 'Tools' matches, but got clear" + ) + else: + pytest.fail(f"can't find '{data.get('message')}'") + + assert hasattr(result[1], "data") # McpImage + assert ( + type(result[1].data).__name__ == "bytes" + ), f"Expected type img_bytes, got {type(result[1].data).__name__}" diff --git a/uv.lock b/uv.lock index 7ccb2dd..6e2511f 100755 --- a/uv.lock +++ b/uv.lock @@ -1727,7 +1727,7 @@ wheels = [ [[package]] name = "windows-mcp" -version = "0.6.8" +version = "0.6.9" source = { editable = "." } dependencies = [ { name = "click" }, @@ -1747,6 +1747,15 @@ dependencies = [ { name = "tabulate" }, { name = "thefuzz" }, { name = "uuid7" }, + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, + { name = "winrt-windows-globalization" }, + { name = "winrt-windows-graphics-imaging" }, + { name = "winrt-windows-media-ocr" }, + { name = "winrt-windows-security-cryptography" }, + { name = "winrt-windows-storage" }, + { name = "winrt-windows-storage-streams" }, ] [package.optional-dependencies] @@ -1756,6 +1765,12 @@ dev = [ { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.1" }, @@ -1778,9 +1793,177 @@ requires-dist = [ { name = "tabulate", specifier = ">=0.9.0" }, { name = "thefuzz", specifier = ">=0.22.1" }, { name = "uuid7", specifier = ">=0.1.0" }, + { name = "winrt-runtime", specifier = ">=3.2.1" }, + { name = "winrt-windows-foundation", specifier = ">=3.2.1" }, + { name = "winrt-windows-foundation-collections", specifier = ">=3.2.1" }, + { name = "winrt-windows-globalization", specifier = ">=3.2.1" }, + { name = "winrt-windows-graphics-imaging", specifier = ">=3.2.1" }, + { name = "winrt-windows-media-ocr", specifier = ">=3.2.1" }, + { name = "winrt-windows-security-cryptography", specifier = ">=3.2.1" }, + { name = "winrt-windows-storage", specifier = ">=3.2.1" }, + { name = "winrt-windows-storage-streams", specifier = ">=3.2.1" }, ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-globalization" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/aa/83c0bd92a9e9f0143d8698678b42956cab5c18118511d02a809b42776ed2/winrt_windows_globalization-3.2.1.tar.gz", hash = "sha256:bf81ba15b33b34d94f68c0c97a3916c57c5a624b55abf409811320dee2a4dab4", size = 25284, upload-time = "2025-06-06T14:42:01.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/12/d5b9318b1577d46d8b0a84b6e49c0563409334292d6a19089fbd9f68b3b0/winrt_windows_globalization-3.2.1-cp313-cp313-win32.whl", hash = "sha256:c0bdba6dcedfa1ca894fe8de2c58cb23274d524dc048e1585d383c2732a666a5", size = 114824, upload-time = "2025-06-06T07:13:45.627Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/6f16a70825678eb29b3afa0cdeec6d1250f4c15806bd13dcf863e3177ddd/winrt_windows_globalization-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e03c3339a9632bf855c7f24479d6014627dc15882a12a712f2706c2380e094cf", size = 137649, upload-time = "2025-06-06T07:13:46.816Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2e/8fef8b38162a6644b75b99b0e52612374ffda592e4ea646c33ba53afc1ae/winrt_windows_globalization-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ae8cbc0fd548afc2733c6550842d121139f2e85db9763c7e5afd740f0e921437", size = 111307, upload-time = "2025-06-06T07:13:47.747Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f7/f9bda18a5355e6726415c43a1c1e158a5f28f5c0523c9692136db08bc2f0/winrt_windows_globalization-3.2.1-cp314-cp314-win32.whl", hash = "sha256:487772ddae211ad5dbe0364edc5443e1a091b713ed4cf67d753fb20e212065b4", size = 117338, upload-time = "2025-09-20T07:12:20.934Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/8da6d8bcc275063f7f7484c6fdaadfa4a00e42e399e90a80935db906d120/winrt_windows_globalization-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:60761540a6acb1273ea024e98616996522f485075ae48e5954d797896c5e2fc2", size = 141802, upload-time = "2025-09-20T07:12:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/cc/83/420b2eb571e095b498aad274d3a77518f7c2237f1dcdf39de826e175e46d/winrt_windows_globalization-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:9cb07af0c26f2fdb144fd2806a6c0de30b6c78f3afdf5a574375dd77de4574ff", size = 120277, upload-time = "2025-09-20T07:12:22.564Z" }, +] + +[[package]] +name = "winrt-windows-graphics-imaging" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/90/14655f93d5cc4224ae6c44866c36928926b54c8fe7ae4a465b10332bd935/winrt_windows_graphics_imaging-3.2.1.tar.gz", hash = "sha256:1e0bdb08625b0ce144a2e76dd5ed86605906e41196df92d660c8f87a993a1513", size = 26786, upload-time = "2025-06-06T14:42:11.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/7a/3e2b38e3d5526981918a64b8e8970c10a86ad2e3fe142f2c6313349b7318/winrt_windows_graphics_imaging-3.2.1-cp313-cp313-win32.whl", hash = "sha256:9bcff883e1bb9819a651ad1151779b30fd3c7ed838e1f59e894984b9df54e54b", size = 133272, upload-time = "2025-06-06T07:17:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/7bdb36146c7a28856d5a81f65289d5d74d7ba1f2192ef4b1414b24834425/winrt_windows_graphics_imaging-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:7026df3274e20f72678b5d1a1425358004d9fe4316b775e413123841936345a5", size = 142428, upload-time = "2025-06-06T07:17:12.212Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/5fac538512c42c9e3b7baa6b2f011d69eb56ec8a508020a8fa1e532f2f35/winrt_windows_graphics_imaging-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8eb06ba156e65a42ef47bf18d2fc7cd3f558cc72af3b2743fb5f8ef92778117", size = 136049, upload-time = "2025-06-06T07:17:13.093Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/04e7c69a3869c1d5ad8b99ca488a1d2a08485c5bc0cc5952fd096ddc035f/winrt_windows_graphics_imaging-3.2.1-cp314-cp314-win32.whl", hash = "sha256:7e4a41592dc0f7e0275fe46f86d916cc15aa85afde4f08110b6aa19cfbc9265e", size = 138059, upload-time = "2025-09-20T07:13:00.847Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1e/69b3d3b221545c0d47498d52e7080c1a791af80140f4fd622e673f5a5bc0/winrt_windows_graphics_imaging-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:c9ed3f4805c205e15696993a4ee099ae36ccb4c4cbaa05415e8db1bf71d5ccc6", size = 146894, upload-time = "2025-09-20T07:13:01.764Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8c/b4916433e2f4aeb1b7b1850367494593f9c51c9c21c3edc033a8e817db69/winrt_windows_graphics_imaging-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3b7f81a839e4c6a68cddc1ec89b02aa7ed5a8cb8e6f8e669a13a5771439a7568", size = 142316, upload-time = "2025-09-20T07:13:03.415Z" }, +] + +[[package]] +name = "winrt-windows-media-ocr" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/cd/43353a1ddc388cd9ae4ed9ca090d53239aa17f1210616a5dbf18957def41/winrt_windows_media_ocr-3.2.1.tar.gz", hash = "sha256:521e63c42073ee911f373aa24a67eea0f2903b3bb91febdbc34875b254f2b66e", size = 6674, upload-time = "2025-06-06T14:42:43.356Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/33/4af8858c0e7e637910b9226179c458456aa185be02ba3c7483d5a83edba3/winrt_windows_media_ocr-3.2.1-cp313-cp313-win32.whl", hash = "sha256:74e3e3c060656ea80cd961adaffe2461fc0233fc9e4658feb3469cdf77ba7c02", size = 41110, upload-time = "2025-06-06T07:26:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/adf14ce4187aab3eceb87e713d266ddf68a95a2dc360463b06adf7eab32f/winrt_windows_media_ocr-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8252e790211a0ddace4513988dc4c36edaf727060cf5f6d4f1a1e11bc0020854", size = 42065, upload-time = "2025-06-06T07:26:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/9a9331059235b12542d7b6370cf668c1f802a0392a1e41d640f57975f0c8/winrt_windows_media_ocr-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d241eb5616772031f691111a8e0c70729bf8372ea552fdcb1a35544b7e9e832", size = 39397, upload-time = "2025-06-06T07:26:01.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/ab/2542c775fda7ebca4629736d73d59c28fe3b5cd56ddfccb6b5e1f1232153/winrt_windows_media_ocr-3.2.1-cp314-cp314-win32.whl", hash = "sha256:c67fa675947e40f13c58ef4a1c49f8c779d3e6f1fdc91733a506a5e41ebf0007", size = 42227, upload-time = "2025-09-20T07:14:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ce/719e981c55cec68feab1fab2e5012938c0846247ba3af30face2ca41a3ad/winrt_windows_media_ocr-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:31a620f6704cf8a27bc38a5e4f7a7888c935aecc5443c6dfbfddca331259e2af", size = 43578, upload-time = "2025-09-20T07:14:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/d4/52/c39c0d5cc1e9f506a09b28ad40c51a183cf62e454dc30fa17c1ef16554e5/winrt_windows_media_ocr-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:e731cf200a4731f53c0e40fba72e0312e895f80ab4e3574dc45ef3b1e35509ef", size = 40765, upload-time = "2025-09-20T07:14:43.679Z" }, +] + +[[package]] +name = "winrt-windows-security-cryptography" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/f6/cb1a6a5974ab735c76c67837ab0fd7ef63700feffc3aecb55d48edc25a78/winrt_windows_security_cryptography-3.2.1.tar.gz", hash = "sha256:100d4459e1199b4b887dfe23d1feccfa40950bee978f7f4d3a7d27a3d266523e", size = 5519, upload-time = "2025-06-06T14:43:07.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/d9/78e468e98e2146c1f8dc99f4fb67db9316058e18c71f3453fe2a52147d79/winrt_windows_security_cryptography-3.2.1-cp313-cp313-win32.whl", hash = "sha256:c67978943b711ee7cd25af9a9d9f4b9a3314725df574c566011875e7f20caa69", size = 24986, upload-time = "2025-06-06T13:56:30.392Z" }, + { url = "https://files.pythonhosted.org/packages/39/9b/f6b72ba0c3897f0a969f8dbac5f97418b397f8b642ba1b1462314ebdea78/winrt_windows_security_cryptography-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:caf129e3bfeaf9b970decc4407f3f206d265c2b88db828983acb7fa37aee1a47", size = 27403, upload-time = "2025-06-06T13:56:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/7a/37/fdd88ba90531416b2f701d479cb79ef4e320226cc93992d8c7733c857fd2/winrt_windows_security_cryptography-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:22936cd3598d9e51833333c3dd8479aba7d6799c5d1a27fdc79d921b0279db2e", size = 22662, upload-time = "2025-06-06T13:56:32.564Z" }, + { url = "https://files.pythonhosted.org/packages/af/83/7bf4233a762e3cfb5e4c7424133cb917530385e5fb2f98ead1e54d25d902/winrt_windows_security_cryptography-3.2.1-cp314-cp314-win32.whl", hash = "sha256:0a86bd327437fc941f5a83436c0c93d9d4ab61dc0577ebe39e4c7cdc325d8a48", size = 25390, upload-time = "2025-09-20T07:16:15.6Z" }, + { url = "https://files.pythonhosted.org/packages/9d/63/3db241751a62e10a55557e61cabb1a81a7d96c7c8a3f3fd1b35730bd9c6b/winrt_windows_security_cryptography-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:8f1c0840d9d2a574f248b12c9d7ef3060417f544c38a30c95972e37bf0befd0c", size = 27978, upload-time = "2025-09-20T07:16:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7d/f2ba1c0c0de4c74189f36b96ffd773dd849ac3a90de3f57694ab68a7631f/winrt_windows_security_cryptography-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:8525f9aaec8c6e42fd8efa64816a99483a3a05368a138f2ec4862bb925938766", size = 23425, upload-time = "2025-09-20T07:16:17.005Z" }, +] + +[[package]] +name = "winrt-windows-storage" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/a6/430d4881d214647e2e6c95bb2894bb9202c98bff5f82eca6ba5b5e5d09a1/winrt_windows_storage-3.2.1.tar.gz", hash = "sha256:c7d1c2326fd842babbf98d944d4991b7592ee8433c762af0a8b47696437fae0b", size = 48309, upload-time = "2025-06-06T14:43:17.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/8d/74ebfa9ee9e06350f850e3305c2d902910dbf8412436513cfb8e6e50f6ef/winrt_windows_storage-3.2.1-cp313-cp313-win32.whl", hash = "sha256:fc0567ff2303bcf7d471dba178a997bfdab57371e8282e90a40cc522d7fb4421", size = 239308, upload-time = "2025-06-06T13:59:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/47f63309001b480a8818eb5f26bbab571cdcb463531619f39b5004149610/winrt_windows_storage-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:7a57e022bc8e7096a12f2b530ab544a71ede704a01cdfde75009ed40897b696e", size = 264719, upload-time = "2025-06-06T14:00:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/b4c5760fa183460c27144d3e05a5a793b181d182bec75573749d95268644/winrt_windows_storage-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:7827a46098e0b0c97fdb085c1ebb4de0b130f36402723d9f539a1c1fb399c8e7", size = 261652, upload-time = "2025-06-06T14:00:01.297Z" }, + { url = "https://files.pythonhosted.org/packages/1c/2e/7dfe8b9c740e6f20503e14d6f6c84d9174b0fb22c3e83d82071dd3d638e2/winrt_windows_storage-3.2.1-cp314-cp314-win32.whl", hash = "sha256:b7d5739001d435c914da3354e2c173d3f3bc48b344ebf294308685db82240376", size = 244742, upload-time = "2025-09-20T07:16:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/36/0a/6e3474176fc2aa7a671000af8b7d2a10dc390cbd871f250e352dbefa79c4/winrt_windows_storage-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4f1fff7846f934f53b4cdc7089faa6d21ced9bc993215e95c4fcc716da851e7", size = 272760, upload-time = "2025-09-20T07:16:53.61Z" }, + { url = "https://files.pythonhosted.org/packages/20/bb/02a0c692be2ea6d5bd4db1b3559e3eea96dae774826cc2bec6085f272bdb/winrt_windows_storage-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:78d6191e1daabd4b6aff31b84cc890e5483f4e168bceb0ef6d099ec32af9b835", size = 275463, upload-time = "2025-09-20T07:16:54.597Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] + [[package]] name = "zipp" version = "3.23.0"