From 9d7b11b9ee869d989f957709e25a0935fe8984a4 Mon Sep 17 00:00:00 2001 From: Nancy B Date: Sun, 15 Mar 2026 13:51:28 -0700 Subject: [PATCH 1/3] feat: add Windows support and refactor screen capture logic - Extracted macOS-specific Quartz and Shapely logic into a modular CaptureMac backend. - Implemented native Windows support in CaptureWindows using ctypes and Win32 APIs. - Refactored Screen observer to be platform-agnostic and avoid Mac-only imports on Windows. - Optimized dependencies with platform markers for Quartz and Shapely. - Added missing dependencies (aiohttp, bs4, ics, persist-queue) and resolved tatsu compatibility issues. - Documented pre-existing Calendar observer incompatibility with Python 3.10+. --- gum/observers/__init__.py | 5 +- gum/observers/_capture_base.py | 56 +++++++ gum/observers/_capture_mac.py | 121 +++++++++++++++ gum/observers/_capture_windows.py | 179 ++++++++++++++++++++++ gum/observers/screen.py | 245 +++++------------------------- pyproject.toml | 9 +- setup.py | 9 +- 7 files changed, 415 insertions(+), 209 deletions(-) create mode 100644 gum/observers/_capture_base.py create mode 100644 gum/observers/_capture_mac.py create mode 100644 gum/observers/_capture_windows.py diff --git a/gum/observers/__init__.py b/gum/observers/__init__.py index 6f1533b..12e0e8f 100644 --- a/gum/observers/__init__.py +++ b/gum/observers/__init__.py @@ -6,6 +6,9 @@ from .observer import Observer from .screen import Screen -from .calendar import Calendar +# TODO: Calendar observer disabled due to ics/tatsu incompatibility with Python 3.10+ +# See: tatsu 4.4.0 uses `from collections import Mapping` (removed in 3.10) +# Fix: upgrade ics to >=0.8 and update calendar.py for the new API. +# from .calendar import Calendar __all__ = ["Observer", "Screen", "Calendar"] \ No newline at end of file diff --git a/gum/observers/_capture_base.py b/gum/observers/_capture_base.py new file mode 100644 index 0000000..bf33fda --- /dev/null +++ b/gum/observers/_capture_base.py @@ -0,0 +1,56 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Optional + +class CaptureBase(ABC): + """ + Abstract base class for platform-specific screen capture and window management. + """ + + @abstractmethod + def get_monitor_geometries(self) -> List[Dict[str, int]]: + """ + Returns a list of dictionaries containing geometry for all active monitors. + + Expected keys: 'left', 'top', 'width', 'height'. + Coordinates should be in the OS's global coordinate system. + """ + pass + + @abstractmethod + def is_any_app_visible(self, app_names: List[str]) -> bool: + """ + Checks if any application in the provided list has at least one + visible, non-minimized window on any screen. + + Args: + app_names: A list of application names/titles to check for. + + Returns: + True if at least one matching application window is visible. + """ + pass + + @abstractmethod + def get_monitor_at_point(self, x: float, y: float) -> Optional[Dict[str, int]]: + """ + Returns the geometry dictionary of the monitor containing the given global coordinates. + + Args: + x: The horizontal global coordinate. + y: The vertical global coordinate. + + Returns: + A dictionary with 'left', 'top', 'width', 'height' keys, or None if the point + is off-screen. + """ + pass + + @abstractmethod + def get_window_list(self) -> List[Dict]: + """ + Returns a raw list of metadata for all currently onscreen windows. + + Used primarily for debugging and advanced filtering. + Expected keys: 'owner_name', 'title', 'bounds', 'is_visible'. + """ + pass diff --git a/gum/observers/_capture_mac.py b/gum/observers/_capture_mac.py new file mode 100644 index 0000000..3ba719b --- /dev/null +++ b/gum/observers/_capture_mac.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from typing import List, Dict, Optional, Iterable + +import Quartz +from shapely.geometry import box +from shapely.ops import unary_union + +from ._capture_base import CaptureBase + +class CaptureMac(CaptureBase): + """ + macOS-specific screen capture and window management using Quartz. + """ + + def _get_global_bounds(self) -> tuple[float, float, float, float]: + """Return a bounding box enclosing **all** physical displays.""" + err, ids, cnt = Quartz.CGGetActiveDisplayList(16, None, None) + if err != Quartz.kCGErrorSuccess: + raise OSError(f"CGGetActiveDisplayList failed: {err}") + + min_x = min_y = float("inf") + max_x = max_y = -float("inf") + for did in ids[:cnt]: + r = Quartz.CGDisplayBounds(did) + x0, y0 = r.origin.x, r.origin.y + x1, y1 = x0 + r.size.width, y0 + r.size.height + min_x, min_y = min(min_x, x0), min(min_y, y0) + max_x, max_y = max(max_x, x1), max(max_y, y1) + return min_x, min_y, max_x, max_y + + def get_monitor_geometries(self) -> List[Dict[str, int]]: + """Returns a list of monitor geometries using Quartz.""" + err, ids, cnt = Quartz.CGGetActiveDisplayList(16, None, None) + if err != Quartz.kCGErrorSuccess: + raise OSError(f"CGGetActiveDisplayList failed: {err}") + + monitors = [] + for did in ids[:cnt]: + r = Quartz.CGDisplayBounds(did) + monitors.append({ + "left": int(r.origin.x), + "top": int(r.origin.y), + "width": int(r.size.width), + "height": int(r.size.height) + }) + return monitors + + def get_window_list(self) -> List[Dict]: + """List onscreen windows using Quartz.""" + opts = ( + Quartz.kCGWindowListOptionOnScreenOnly + | Quartz.kCGWindowListOptionIncludingWindow + ) + wins = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID) + + result = [] + for info in wins: + bounds = info.get("kCGWindowBounds", {}) + result.append({ + "owner_name": info.get("kCGWindowOwnerName", ""), + "title": info.get("kCGWindowName", ""), + "bounds": { + "X": bounds.get("X", 0), + "Y": bounds.get("Y", 0), + "Width": bounds.get("Width", 0), + "Height": bounds.get("Height", 0), + }, + "is_visible": True # Since we use kCGWindowListOptionOnScreenOnly + }) + return result + + def is_any_app_visible(self, app_names: List[str]) -> bool: + """Determines app visibility using Quartz and Shapely for area calculation.""" + if not app_names: + return False + + _, _, _, gmax_y = self._get_global_bounds() + targets = set(app_names) + + opts = ( + Quartz.kCGWindowListOptionOnScreenOnly + | Quartz.kCGWindowListOptionIncludingWindow + ) + wins = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID) + + occupied = None + for info in wins: + owner = info.get("kCGWindowOwnerName", "") + if owner in ("Dock", "WindowServer", "Window Server"): + continue + + bounds = info.get("kCGWindowBounds", {}) + x, y, w, h = ( + bounds.get("X", 0), + bounds.get("Y", 0), + bounds.get("Width", 0), + bounds.get("Height", 0), + ) + if w <= 0 or h <= 0: + continue + + inv_y = gmax_y - y - h + poly = box(x, inv_y, x + w, inv_y + h) + if poly.is_empty: + continue + + visible = poly if occupied is None else poly.difference(occupied) + if not visible.is_empty: + if owner in targets: + return True # Found a visible window for one of the target apps + occupied = poly if occupied is None else unary_union([occupied, poly]) + + return False + + def get_monitor_at_point(self, x: float, y: float) -> Optional[Dict[str, int]]: + """Finds the monitor geometry containing the point using Quartz bounds.""" + monitors = self.get_monitor_geometries() + for m in monitors: + if m["left"] <= x < m["left"] + m["width"] and m["top"] <= y < m["top"] + m["height"]: + return m + return None diff --git a/gum/observers/_capture_windows.py b/gum/observers/_capture_windows.py new file mode 100644 index 0000000..d30eb41 --- /dev/null +++ b/gum/observers/_capture_windows.py @@ -0,0 +1,179 @@ +from __future__ import annotations +import ctypes +import ctypes.wintypes +import os +from typing import List, Dict, Optional + +# --- Win32 API Constants and Structures --- +GWL_EXSTYLE = -20 +WS_EX_TOOLWINDOW = 0x00000080 + +# Process Access Constants +PROCESS_QUERY_INFORMATION = 0x0400 +PROCESS_VM_READ = 0x0010 + +class RECT(ctypes.Structure): + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] + +class MONITORINFOEXW(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.wintypes.DWORD), + ("rcMonitor", RECT), + ("rcWork", RECT), + ("dwFlags", ctypes.wintypes.DWORD), + ("szDevice", ctypes.wintypes.WCHAR * 32), + ] + +# --- DLL Loads --- +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 +psapi = ctypes.windll.psapi + +from ._capture_base import CaptureBase + +class CaptureWindows(CaptureBase): + """ + Windows-specific screen capture and window management. + (Placeholder implementation) + """ + + def _get_window_owner(self, hwnd: int) -> str: + """Helper to get the process name (executable name) for a window handle.""" + pid = ctypes.wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + + process_handle = kernel32.OpenProcess( + PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid + ) + if not process_handle: + return "" + + buffer = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) + success = psapi.GetModuleFileNameExW(process_handle, 0, buffer, ctypes.sizeof(buffer)) + kernel32.CloseHandle(process_handle) + + if success: + exe_path = buffer.value + return os.path.splitext(os.path.basename(exe_path))[0] + return "" + + def get_monitor_geometries(self) -> List[Dict[str, int]]: + """Placeholder for Windows monitor geometry discovery.""" + monitors = [] + + def callback(hMonitor, hdcMonitor, lprcMonitor, dwData): + info = MONITORINFOEXW() + info.cbSize = ctypes.sizeof(MONITORINFOEXW) + if user32.GetMonitorInfoW(hMonitor, ctypes.byref(info)): + monitors.append({ + "left": int(info.rcMonitor.left), + "top": int(info.rcMonitor.top), + "width": int(info.rcMonitor.right - info.rcMonitor.left), + "height": int(info.rcMonitor.bottom - info.rcMonitor.top), + }) + return True + + MonitorEnumProc = ctypes.WINFUNCTYPE( + ctypes.wintypes.BOOL, + ctypes.wintypes.HMONITOR, + ctypes.wintypes.HDC, + ctypes.POINTER(RECT), + ctypes.wintypes.LPARAM + ) + + user32.EnumDisplayMonitors(None, None, MonitorEnumProc(callback), 0) + return monitors + + def is_any_app_visible(self, app_names: List[str]) -> bool: + """Placeholder for Windows app visibility check.""" + if not app_names: + return False + + targets = {name.lower() for name in app_names} + found = [False] # Use a list to allow modification inside callback + + def callback(hwnd, lparam): + if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd): + return True + + owner = self._get_window_owner(hwnd).lower() + if owner in targets: + rect = RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + if (rect.right > rect.left) and (rect.bottom > rect.top): + found[0] = True + return False # Stop enumerating + return True + + EnumWindowsProc = ctypes.WINFUNCTYPE( + ctypes.wintypes.BOOL, + ctypes.wintypes.HWND, + ctypes.wintypes.LPARAM + ) + user32.EnumWindows(EnumWindowsProc(callback), 0) + return found[0] + + def get_monitor_at_point(self, x: float, y: float) -> Optional[Dict[str, int]]: + """Placeholder for Windows monitor-at-point discovery.""" + point = ctypes.wintypes.POINT(int(x), int(y)) + hMonitor = user32.MonitorFromPoint(point, 0) # MONITOR_DEFAULTTONULL + + if hMonitor: + info = MONITORINFOEXW() + info.cbSize = ctypes.sizeof(MONITORINFOEXW) + if user32.GetMonitorInfoW(hMonitor, ctypes.byref(info)): + return { + "left": int(info.rcMonitor.left), + "top": int(info.rcMonitor.top), + "width": int(info.rcMonitor.right - info.rcMonitor.left), + "height": int(info.rcMonitor.bottom - info.rcMonitor.top), + } + return None + + def get_window_list(self) -> List[Dict]: + """Placeholder for Windows window metadata retrieval.""" + windows = [] + + def callback(hwnd, lparam): + if not user32.IsWindowVisible(hwnd): + return True + + ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + if ex_style & WS_EX_TOOLWINDOW: + return True + + length = user32.GetWindowTextLengthW(hwnd) + if length == 0: + return True + + title_buffer = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, title_buffer, length + 1) + + rect = RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + + windows.append({ + "owner_name": self._get_window_owner(hwnd), + "title": title_buffer.value, + "bounds": { + "X": rect.left, + "Y": rect.top, + "Width": rect.right - rect.left, + "Height": rect.bottom - rect.top, + }, + "is_visible": True + }) + return True + + EnumWindowsProc = ctypes.WINFUNCTYPE( + ctypes.wintypes.BOOL, + ctypes.wintypes.HWND, + ctypes.wintypes.LPARAM + ) + user32.EnumWindows(EnumWindowsProc(callback), 0) + return windows diff --git a/gum/observers/screen.py b/gum/observers/screen.py index 726a449..dda690a 100644 --- a/gum/observers/screen.py +++ b/gum/observers/screen.py @@ -8,6 +8,7 @@ import logging import os import time +import platform from collections import deque from typing import Any, Dict, Iterable, List, Optional @@ -15,11 +16,8 @@ # — Third-party — import mss -import Quartz from PIL import Image from pynput import mouse # still synchronous -from shapely.geometry import box -from shapely.ops import unary_union # — Local — from .observer import Observer @@ -31,88 +29,6 @@ # — Local — from gum.prompts.screen import TRANSCRIPTION_PROMPT, SUMMARY_PROMPT -############################################################################### -# Window‑geometry helpers # -############################################################################### - - -def _get_global_bounds() -> tuple[float, float, float, float]: - """Return a bounding box enclosing **all** physical displays. - - Returns - ------- - (min_x, min_y, max_x, max_y) tuple in Quartz global coordinates. - """ - err, ids, cnt = Quartz.CGGetActiveDisplayList(16, None, None) - if err != Quartz.kCGErrorSuccess: # pragma: no cover (defensive) - raise OSError(f"CGGetActiveDisplayList failed: {err}") - - min_x = min_y = float("inf") - max_x = max_y = -float("inf") - for did in ids[:cnt]: - r = Quartz.CGDisplayBounds(did) - x0, y0 = r.origin.x, r.origin.y - x1, y1 = x0 + r.size.width, y0 + r.size.height - min_x, min_y = min(min_x, x0), min(min_y, y0) - max_x, max_y = max(max_x, x1), max(max_y, y1) - return min_x, min_y, max_x, max_y - - -def _get_visible_windows() -> List[tuple[dict, float]]: - """List *onscreen* windows with their visible‑area ratio. - - Each tuple is ``(window_info_dict, visible_ratio)`` where *visible_ratio* - is in ``[0.0, 1.0]``. Internal system windows (Dock, WindowServer, …) are - ignored. - """ - _, _, _, gmax_y = _get_global_bounds() - - opts = ( - Quartz.kCGWindowListOptionOnScreenOnly - | Quartz.kCGWindowListOptionIncludingWindow - ) - wins = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID) - - occupied = None # running union of opaque regions above the current window - result: list[tuple[dict, float]] = [] - - for info in wins: - owner = info.get("kCGWindowOwnerName", "") - if owner in ("Dock", "WindowServer", "Window Server"): - continue - - bounds = info.get("kCGWindowBounds", {}) - x, y, w, h = ( - bounds.get("X", 0), - bounds.get("Y", 0), - bounds.get("Width", 0), - bounds.get("Height", 0), - ) - if w <= 0 or h <= 0: - continue # hidden or minimised - - inv_y = gmax_y - y - h # Quartz→Shapely Y‑flip - poly = box(x, inv_y, x + w, inv_y + h) - if poly.is_empty: - continue - - visible = poly if occupied is None else poly.difference(occupied) - if not visible.is_empty: - ratio = visible.area / poly.area - result.append((info, ratio)) - occupied = poly if occupied is None else unary_union([occupied, poly]) - - return result - - -def _is_app_visible(names: Iterable[str]) -> bool: - """Return *True* if **any** window from *names* is at least partially visible.""" - targets = set(names) - return any( - info.get("kCGWindowOwnerName", "") in targets and ratio > 0 - for info, ratio in _get_visible_windows() - ) - ############################################################################### # Screen observer # ############################################################################### @@ -139,12 +55,10 @@ class Screen(Observer): Attributes: _CAPTURE_FPS (int): Frames per second for screen capture. _DEBOUNCE_SEC (int): Seconds to wait before processing an interaction. - _MON_START (int): Index of first real display in mss. """ _CAPTURE_FPS: int = 10 _DEBOUNCE_SEC: int = 2 - _MON_START: int = 1 # first real display in mss # ─────────────────────────────── construction def __init__( @@ -159,20 +73,7 @@ def __init__( api_key: str | None = None, api_base: str | None = None, ) -> None: - """Initialize the Screen observer. - - Args: - screenshots_dir (str, optional): Directory to store screenshots. Defaults to "~/.cache/gum/screenshots". - skip_when_visible (Optional[str | list[str]], optional): Application names to skip when visible. - Defaults to None. - transcription_prompt (Optional[str], optional): Custom prompt for transcribing screenshots. - Defaults to None. - summary_prompt (Optional[str], optional): Custom prompt for summarizing screenshots. - Defaults to None. - model_name (str, optional): GPT model to use for vision analysis. Defaults to "gpt-4o-mini". - history_k (int, optional): Number of recent screenshots to keep in history. Defaults to 10. - debug (bool, optional): Enable debug logging. Defaults to False. - """ + """Initialize the Screen observer.""" self.screens_dir = os.path.abspath(os.path.expanduser(screenshots_dir)) os.makedirs(self.screens_dir, exist_ok=True) @@ -184,6 +85,16 @@ def __init__( self.debug = debug + # platform-specific capture logic + if platform.system() == "Darwin": + from ._capture_mac import CaptureMac + self.capture = CaptureMac() + elif platform.system() == "Windows": + from ._capture_windows import CaptureWindows + self.capture = CaptureWindows() + else: + raise OSError(f"Unsupported platform: {platform.system()}") + # state shared with worker self._frames: Dict[int, Any] = {} self._frame_lock = asyncio.Lock() @@ -192,10 +103,7 @@ def __init__( self._pending_event: Optional[dict] = None self._debounce_handle: Optional[asyncio.TimerHandle] = None self.client = AsyncOpenAI( - # try the class, then the env for screen, then the env for gum base_url=api_base or os.getenv("SCREEN_LM_API_BASE") or os.getenv("GUM_LM_API_BASE"), - - # try the class, then the env for screen, then the env for GUM, then none api_key=api_key or os.getenv("SCREEN_LM_API_KEY") or os.getenv("GUM_LM_API_KEY") or os.getenv("OPENAI_API_KEY") or "None" ) @@ -203,47 +111,15 @@ def __init__( super().__init__() # ─────────────────────────────── tiny sync helpers - @staticmethod - def _mon_for(x: float, y: float, mons: list[dict]) -> Optional[int]: - """Find which monitor contains the given coordinates. - - Args: - x (float): X coordinate. - y (float): Y coordinate. - mons (list[dict]): List of monitor information dictionaries. - - Returns: - Optional[int]: Monitor index if found, None otherwise. - """ - for idx, m in enumerate(mons, 1): - if m["left"] <= x < m["left"] + m["width"] and m["top"] <= y < m["top"] + m["height"]: - return idx - return None - @staticmethod def _encode_image(img_path: str) -> str: - """Encode an image file as base64. - - Args: - img_path (str): Path to the image file. - - Returns: - str: Base64 encoded image data. - """ + """Encode an image file as base64.""" with open(img_path, "rb") as fh: return base64.b64encode(fh.read()).decode() # ─────────────────────────────── OpenAI Vision (async) async def _call_gpt_vision(self, prompt: str, img_paths: list[str]) -> str: - """Call GPT Vision API to analyze images. - - Args: - prompt (str): Prompt to guide the analysis. - img_paths (list[str]): List of image paths to analyze. - - Returns: - str: GPT's analysis of the images. - """ + """Call GPT Vision API to analyze images.""" content = [ { "type": "image_url", @@ -264,15 +140,7 @@ async def _call_gpt_vision(self, prompt: str, img_paths: list[str]) -> str: # ─────────────────────────────── I/O helpers async def _save_frame(self, frame, tag: str) -> str: - """Save a frame as a JPEG image. - - Args: - frame: Frame data to save. - tag (str): Tag to include in the filename. - - Returns: - str: Path to the saved image. - """ + """Save a frame as a JPEG image.""" ts = f"{time.time():.5f}" path = os.path.join(self.screens_dir, f"{ts}_{tag}.jpg") await asyncio.to_thread( @@ -284,27 +152,20 @@ async def _save_frame(self, frame, tag: str) -> str: return path async def _process_and_emit(self, before_path: str, after_path: str) -> None: - """Process screenshots and emit an update. - - Args: - before_path (str): Path to the "before" screenshot. - after_path (str | None): Path to the "after" screenshot, if any. - """ - # chronology: append 'before' first (history order == real order) + """Process screenshots and emit an update.""" self._history.append(before_path) prev_paths = list(self._history) - # async OpenAI calls try: transcription = await self._call_gpt_vision(self.transcription_prompt, [before_path, after_path]) - except Exception as exc: # pragma: no cover + except Exception as exc: transcription = f"[transcription failed: {exc}]" prev_paths.append(before_path) prev_paths.append(after_path) try: summary = await self._call_gpt_vision(self.summary_prompt, prev_paths) - except Exception as exc: # pragma: no cover + except Exception as exc: summary = f"[summary failed: {exc}]" txt = (transcription + summary).strip() @@ -312,23 +173,12 @@ async def _process_and_emit(self, before_path: str, after_path: str) -> None: # ─────────────────────────────── skip guard def _skip(self) -> bool: - """Check if capture should be skipped based on visible applications. - - Returns: - bool: True if capture should be skipped, False otherwise. - """ - return _is_app_visible(self._guard) if self._guard else False + """Check if capture should be skipped based on visible applications.""" + return self.capture.is_any_app_visible(list(self._guard)) if self._guard else False # ─────────────────────────────── main async worker - async def _worker(self) -> None: # overrides base class - """Main worker method that captures and processes screenshots. - - This method runs in a background task and handles: - - Mouse event monitoring - - Screen capture - - Periodic screenshots - - Image processing and analysis - """ + async def _worker(self) -> None: + """Main worker method that captures and processes screenshots.""" log = logging.getLogger("Screen") if self.debug: logging.basicConfig(level=logging.INFO, format="%(asctime)s [Screen] %(message)s", datefmt="%H:%M:%S") @@ -341,13 +191,9 @@ async def _worker(self) -> None: # overrides base class loop = asyncio.get_running_loop() - # ------------------------------------------------------------------ - # All calls to mss / Quartz are wrapped in `to_thread` - # ------------------------------------------------------------------ with mss.mss() as sct: - mons = sct.monitors[self._MON_START:] + mons = sct.monitors[1:] # mss 1-based index for real displays - # ---- mouse callbacks (pynput is sync → schedule into loop) ---- def schedule_event(x: float, y: float, typ: str): asyncio.run_coroutine_threadsafe(mouse_event(x, y, typ), loop) @@ -358,9 +204,7 @@ def schedule_event(x: float, y: float, typ: str): ) listener.start() - # ---- nested helper inside the async context ---- async def flush(): - """Process pending event and emit update.""" if self._pending_event is None: return if self._skip(): @@ -368,65 +212,60 @@ async def flush(): return ev = self._pending_event - aft = await asyncio.to_thread(sct.grab, mons[ev["mon"] - 1]) + aft = await asyncio.to_thread(sct.grab, mons[ev["mon_idx"]]) bef_path = await self._save_frame(ev["before"], "before") aft_path = await self._save_frame(aft, "after") await self._process_and_emit(bef_path, aft_path) - log.info(f"{ev['type']} captured on monitor {ev['mon']}") + log.info(f"{ev['type']} captured on monitor {ev['mon_idx']}") self._pending_event = None def debounce_flush(): - """Schedule flush as a task.""" asyncio.create_task(flush()) - # ---- mouse event reception ---- async def mouse_event(x: float, y: float, typ: str): - """Handle mouse events. - - Args: - x (float): X coordinate. - y (float): Y coordinate. - typ (str): Event type ("move", "click", or "scroll"). - """ - idx = self._mon_for(x, y, mons) + mon_geo = self.capture.get_monitor_at_point(x, y) log.info( - f"{typ:<6} @({x:7.1f},{y:7.1f}) → mon={idx} {'(guarded)' if self._skip() else ''}" + f"{typ:<6} @({x:7.1f},{y:7.1f}) → mon={mon_geo} {'(guarded)' if self._skip() else ''}" ) - if self._skip() or idx is None: + if self._skip() or mon_geo is None: + return + + # Find which mss monitor this corresponds to + mon_idx = None + for i, m in enumerate(mons): + if m["left"] == mon_geo["left"] and m["top"] == mon_geo["top"]: + mon_idx = i + break + + if mon_idx is None: return - # lazily grab before-frame if self._pending_event is None: async with self._frame_lock: - bf = self._frames.get(idx) + bf = self._frames.get(mon_idx) if bf is None: return - self._pending_event = {"type": typ, "mon": idx, "before": bf} + self._pending_event = {"type": typ, "mon_idx": mon_idx, "before": bf} - # reset debounce timer if self._debounce_handle: self._debounce_handle.cancel() self._debounce_handle = loop.call_later(DEBOUNCE, debounce_flush) - # ---- main capture loop ---- log.info(f"Screen observer started — guarding {self._guard or '∅'}") - while self._running: # flag from base class + while self._running: t0 = time.time() - # refresh 'before' buffers - for idx, m in enumerate(mons, 1): + for idx, m in enumerate(mons): frame = await asyncio.to_thread(sct.grab, m) async with self._frame_lock: self._frames[idx] = frame - # fps throttle dt = time.time() - t0 await asyncio.sleep(max(0, (1 / CAP_FPS) - dt)) - # shutdown listener.stop() if self._debounce_handle: self._debounce_handle.cancel() diff --git a/pyproject.toml b/pyproject.toml index 32de5e1..eff6238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ dependencies = [ "pillow", "mss", "pynput", - "shapely", - "pyobjc-framework-Quartz", + "pyobjc-framework-Quartz; sys_platform == 'darwin'", + "shapely; sys_platform == 'darwin'", "openai>=1.0.0", "SQLAlchemy>=2.0.0", "pydantic>=2.0.0", @@ -21,7 +21,10 @@ dependencies = [ "python-dotenv>=1.0.0", "scikit-learn", "aiosqlite", - "greenlet", + "aiohttp", + "beautifulsoup4", + "ics", + "tatsu<5.0", "persist-queue", "mkdocs>=1.5.0", "mkdocs-material>=9.0.0", diff --git a/setup.py b/setup.py index d9dfb94..43f8f0a 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,8 @@ "pillow", # For image processing "mss", # For screen capture "pynput", # For mouse/keyboard monitoring - "shapely", # For geometry operations - "pyobjc-framework-Quartz", # For macOS window management + "pyobjc-framework-Quartz; sys_platform == 'darwin'", # For macOS window management + "shapely; sys_platform == 'darwin'", # For geometry operations "openai>=1.0.0", "SQLAlchemy>=2.0.0", "pydantic>=2.0.0", @@ -18,6 +18,11 @@ "python-dotenv>=1.0.0", "scikit-learn", "aiosqlite", + "aiohttp", + "beautifulsoup4", + "ics", + "tatsu<5.0", + "persist-queue", "greenlet" ], entry_points={ From a1e76ad49219e9ff520f4de709c4faae6113b80b Mon Sep 17 00:00:00 2001 From: Nancy B Date: Sun, 15 Mar 2026 16:04:30 -0700 Subject: [PATCH 2/3] feat: implement cross-platform screen capture architecture and batching configuration - Added platform-specific capture (Quartz for Mac, Win32 for Windows) - Added --list-apps flag for visible application discovery - Added support for MIN_BATCH_SIZE, MAX_BATCH_SIZE, and DEBOUNCE_SEC env vars - Resolved Python 3.10+ compatibility by disabling Calendar observer - Made Mac-specific dependencies conditional in pyproject.toml --- gum/cli.py | 12 ++++++++ gum/observers/__init__.py | 2 +- gum/observers/_capture_windows.py | 16 ++++++++++- gum/observers/screen.py | 33 +++++++++++++-------- pyproject.toml | 1 + tests/test_cli_list_apps.py | 48 +++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 tests/test_cli_list_apps.py diff --git a/gum/cli.py b/gum/cli.py index 8f1b301..66003ca 100644 --- a/gum/cli.py +++ b/gum/cli.py @@ -34,6 +34,7 @@ def parse_args(): parser.add_argument('--limit', '-l', type=int, help='Limit the number of results', default=10) parser.add_argument('--model', '-m', type=str, help='Model to use') parser.add_argument('--reset-cache', action='store_true', help='Reset the GUM cache and exit') # Add this line + parser.add_argument('--list-apps', action='store_true', help='List currently visible application names and exit') # Batching configuration arguments parser.add_argument('--min-batch-size', type=int, help='Minimum number of observations to trigger batch processing') @@ -66,6 +67,17 @@ async def main(): min_batch_size = args.min_batch_size or int(os.getenv('MIN_BATCH_SIZE', '5')) max_batch_size = args.max_batch_size or int(os.getenv('MAX_BATCH_SIZE', '15')) + if getattr(args, 'list_apps', False): + screen = Screen(model) + windows = screen.capture.get_window_list() + # Use a set to get unique owner names and filter out empty strings + apps = sorted(list(set(w['owner_name'] for w in windows if w.get('owner_name')))) + print("\nVisible applications:") + for app in apps: + print(f" - {app}") + print("-" * 20) + return + # you need one of: user_name for listening mode, --query, or --recent if user_name is None and args.query is None and not getattr(args, 'recent', False): print("Please provide a user name (-u), a query (-q), or use --recent to list latest propositions") diff --git a/gum/observers/__init__.py b/gum/observers/__init__.py index 12e0e8f..ab7b988 100644 --- a/gum/observers/__init__.py +++ b/gum/observers/__init__.py @@ -11,4 +11,4 @@ # Fix: upgrade ics to >=0.8 and update calendar.py for the new API. # from .calendar import Calendar -__all__ = ["Observer", "Screen", "Calendar"] \ No newline at end of file +__all__ = ["Observer", "Screen"] \ No newline at end of file diff --git a/gum/observers/_capture_windows.py b/gum/observers/_capture_windows.py index d30eb41..58f8748 100644 --- a/gum/observers/_capture_windows.py +++ b/gum/observers/_capture_windows.py @@ -33,13 +33,27 @@ class MONITORINFOEXW(ctypes.Structure): user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 psapi = ctypes.windll.psapi +shcore = None +try: + shcore = ctypes.windll.shcore +except (AttributeError, OSError): + pass + +# Set process DPI awareness to ensure physical coordinates +# 1 = PROCESS_SYSTEM_DPI_AWARE, 2 = PROCESS_PER_MONITOR_DPI_AWARE +try: + if shcore and hasattr(shcore, 'SetProcessDpiAwareness'): + shcore.SetProcessDpiAwareness(1) + else: + user32.SetProcessDPIAware() +except Exception: + pass from ._capture_base import CaptureBase class CaptureWindows(CaptureBase): """ Windows-specific screen capture and window management. - (Placeholder implementation) """ def _get_window_owner(self, hwnd: int) -> str: diff --git a/gum/observers/screen.py b/gum/observers/screen.py index dda690a..cb0c0e3 100644 --- a/gum/observers/screen.py +++ b/gum/observers/screen.py @@ -5,6 +5,7 @@ # — Standard library — import base64 +import ctypes import logging import os import time @@ -58,7 +59,7 @@ class Screen(Observer): """ _CAPTURE_FPS: int = 10 - _DEBOUNCE_SEC: int = 2 + _DEBOUNCE_SEC: int = int(os.getenv("DEBOUNCE_SEC", "2")) # ─────────────────────────────── construction def __init__( @@ -212,14 +213,18 @@ async def flush(): return ev = self._pending_event - aft = await asyncio.to_thread(sct.grab, mons[ev["mon_idx"]]) - - bef_path = await self._save_frame(ev["before"], "before") - aft_path = await self._save_frame(aft, "after") - await self._process_and_emit(bef_path, aft_path) - - log.info(f"{ev['type']} captured on monitor {ev['mon_idx']}") - self._pending_event = None + + try: + # mss.grab is fast enough to run in-thread (~10ms) and avoid thread-local storage issues + aft = sct.grab(mons[ev["mon_idx"]]) + bef_path = await self._save_frame(ev["before"], "before") + aft_path = await self._save_frame(aft, "after") + await self._process_and_emit(bef_path, aft_path) + log.info(f"{ev['type']} captured on monitor {ev['mon_idx']}") + except Exception: + pass + finally: + self._pending_event = None def debounce_flush(): asyncio.create_task(flush()) @@ -259,9 +264,13 @@ async def mouse_event(x: float, y: float, typ: str): t0 = time.time() for idx, m in enumerate(mons): - frame = await asyncio.to_thread(sct.grab, m) - async with self._frame_lock: - self._frames[idx] = frame + try: + # mss.grab is fast enough to run in-thread (~10ms) and avoid thread-local storage issues + frame = sct.grab(m) + async with self._frame_lock: + self._frames[idx] = frame + except Exception: + pass dt = time.time() - t0 await asyncio.sleep(max(0, (1 / CAP_FPS) - dt)) diff --git a/pyproject.toml b/pyproject.toml index eff6238..c57f9ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "beautifulsoup4", "ics", "tatsu<5.0", + "greenlet", "persist-queue", "mkdocs>=1.5.0", "mkdocs-material>=9.0.0", diff --git a/tests/test_cli_list_apps.py b/tests/test_cli_list_apps.py new file mode 100644 index 0000000..f40a622 --- /dev/null +++ b/tests/test_cli_list_apps.py @@ -0,0 +1,48 @@ + +import subprocess +import unittest + +class TestCLI(unittest.TestCase): + def test_list_apps(self): + # Run the command and capture output + result = subprocess.run( + ['python', '-m', 'gum.cli', '--list-apps'], + capture_output=True, + text=True + ) + + # Check that it exited successfully + self.assertEqual(result.returncode, 0) + + # Check that it printed the expected header + self.assertIn("Visible applications:", result.stdout) + + # Check that it found at least some apps (assuming something is open during tests) + # In a headless CI environment this might be empty, but locally it should have items. + lines = result.stdout.strip().split('\n') + # Header + at least one app + self.assertGreaterEqual(len(lines), 2) + + def test_list_apps_with_user(self): + # Run with -u but use a mock or just check it starts + # We use a timeout since listening mode runs forever + try: + result = subprocess.run( + ['python', '-m', 'gum.cli', '--list-apps', '-u', 'TestUser'], + capture_output=True, + text=True, + timeout=5 # Give it enough time to list apps and start listening + ) + except subprocess.TimeoutExpired as e: + # This is actually what we expect if it doesn't return! + stdout = e.stdout.decode() if e.stdout else "" + self.assertIn("Visible applications:", stdout) + self.assertIn("Listening to TestUser", stdout) + return + + # If it didn't timeout, it exited early, which might be an error if listening failed + self.assertIn("Visible applications:", result.stdout) + self.assertIn("Listening to TestUser", result.stdout) + +if __name__ == '__main__': + unittest.main() From bd796dc0ddf6b8626c6fd71f216061220acf3d11 Mon Sep 17 00:00:00 2001 From: Nancy B Date: Mon, 16 Mar 2026 13:23:59 -0700 Subject: [PATCH 3/3] fix: clean up docstrings, remove unused import, add debug logging --- gum/observers/_capture_windows.py | 8 ++++---- gum/observers/screen.py | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/gum/observers/_capture_windows.py b/gum/observers/_capture_windows.py index 58f8748..855ee21 100644 --- a/gum/observers/_capture_windows.py +++ b/gum/observers/_capture_windows.py @@ -77,7 +77,7 @@ def _get_window_owner(self, hwnd: int) -> str: return "" def get_monitor_geometries(self) -> List[Dict[str, int]]: - """Placeholder for Windows monitor geometry discovery.""" + """Returns a list of monitor geometries using Win32 API.""" monitors = [] def callback(hMonitor, hdcMonitor, lprcMonitor, dwData): @@ -104,7 +104,7 @@ def callback(hMonitor, hdcMonitor, lprcMonitor, dwData): return monitors def is_any_app_visible(self, app_names: List[str]) -> bool: - """Placeholder for Windows app visibility check.""" + """Determines app visibility by enumerating onscreen windows.""" if not app_names: return False @@ -133,7 +133,7 @@ def callback(hwnd, lparam): return found[0] def get_monitor_at_point(self, x: float, y: float) -> Optional[Dict[str, int]]: - """Placeholder for Windows monitor-at-point discovery.""" + """Finds the monitor geometry containing the point using Win32 API.""" point = ctypes.wintypes.POINT(int(x), int(y)) hMonitor = user32.MonitorFromPoint(point, 0) # MONITOR_DEFAULTTONULL @@ -150,7 +150,7 @@ def get_monitor_at_point(self, x: float, y: float) -> Optional[Dict[str, int]]: return None def get_window_list(self) -> List[Dict]: - """Placeholder for Windows window metadata retrieval.""" + """List onscreen windows using Win32 API.""" windows = [] def callback(hwnd, lparam): diff --git a/gum/observers/screen.py b/gum/observers/screen.py index cb0c0e3..6e688de 100644 --- a/gum/observers/screen.py +++ b/gum/observers/screen.py @@ -5,7 +5,6 @@ # — Standard library — import base64 -import ctypes import logging import os import time @@ -221,8 +220,8 @@ async def flush(): aft_path = await self._save_frame(aft, "after") await self._process_and_emit(bef_path, aft_path) log.info(f"{ev['type']} captured on monitor {ev['mon_idx']}") - except Exception: - pass + except Exception as exc: + log.debug(f"Failed to capture or process interaction: {exc}") finally: self._pending_event = None @@ -269,8 +268,8 @@ async def mouse_event(x: float, y: float, typ: str): frame = sct.grab(m) async with self._frame_lock: self._frames[idx] = frame - except Exception: - pass + except Exception as exc: + log.debug(f"Main capture loop failed for monitor {idx}: {exc}") dt = time.time() - t0 await asyncio.sleep(max(0, (1 / CAP_FPS) - dt))