wintegrate 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wintegrate/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """wintegrate: A CI-first Windows UI automation library designed for unattended runners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _version
7
+
8
+ try:
9
+ __version__ = _version("wintegrate")
10
+ except PackageNotFoundError: # running from a source tree without an install
11
+ __version__ = "0.0.0.dev0"
12
+
13
+ from wintegrate.apps import CALCULATOR, NOTEPAD, AppHandle, AppSpec
14
+ from wintegrate.diagnostics import CensusDiff, ContinuousRecorder, WindowCensus, WindowSnapshot
15
+ from wintegrate.element import UiaElement
16
+ from wintegrate.env import (
17
+ PlatformCapabilities,
18
+ desktop_only,
19
+ env,
20
+ is_windows_desktop,
21
+ is_windows_server,
22
+ server_only,
23
+ )
24
+ from wintegrate.exceptions import (
25
+ ActionVerificationError,
26
+ DiagnosticPipelineError,
27
+ ElementNotFoundError,
28
+ FocusStealDetectedError,
29
+ TextMismatchError,
30
+ WindowDiscoveryTimeoutError,
31
+ WintegrateError,
32
+ )
33
+ from wintegrate.recorder import (
34
+ ActionPlayer,
35
+ RecordedAction,
36
+ TextActionTimelineRecorder,
37
+ inspect_desktop_tree,
38
+ )
39
+ from wintegrate.session import Session, SessionConfig
40
+ from wintegrate.text import count_lines, normalize_line_endings
41
+ from wintegrate.window import Window
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ "Session",
46
+ "SessionConfig",
47
+ "Window",
48
+ "UiaElement",
49
+ "AppSpec",
50
+ "AppHandle",
51
+ "NOTEPAD",
52
+ "CALCULATOR",
53
+ "env",
54
+ "PlatformCapabilities",
55
+ "is_windows_server",
56
+ "is_windows_desktop",
57
+ "desktop_only",
58
+ "server_only",
59
+ "normalize_line_endings",
60
+ "count_lines",
61
+ "WintegrateError",
62
+ "WindowDiscoveryTimeoutError",
63
+ "ElementNotFoundError",
64
+ "ActionVerificationError",
65
+ "TextMismatchError",
66
+ "FocusStealDetectedError",
67
+ "DiagnosticPipelineError",
68
+ "ContinuousRecorder",
69
+ "WindowCensus",
70
+ "WindowSnapshot",
71
+ "CensusDiff",
72
+ "RecordedAction",
73
+ "TextActionTimelineRecorder",
74
+ "ActionPlayer",
75
+ "inspect_desktop_tree",
76
+ ]
wintegrate/apps.py ADDED
@@ -0,0 +1,100 @@
1
+ """Well-known application specs and a managed application lifecycle handle.
2
+
3
+ These package the launch/discovery pitfalls of modern Windows apps so callers
4
+ don't have to rediscover them:
5
+
6
+ - Titles are localized; process image names, window classes, and UIA control
7
+ types are not. AppSpec matching prefers the locale-independent identities and
8
+ keeps ``title_pattern`` only as a last-resort fallback (this is also where
9
+ localized variants live, once, instead of in every caller).
10
+ - Store apps are single-instance: a leaked instance makes the next launch open
11
+ a tab in the old window instead of a new top-level window, breaking window
12
+ discovery. ``fresh`` sweeps leftover instances before launching.
13
+ - Cold Store-app starts regularly exceed 10s on CI runners (ARM64 especially),
14
+ so managed launches default to a generous discovery timeout.
15
+ - A launch that times out must not leak: AppHandle is a context manager that
16
+ guarantees window close + process kill on exit.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+
25
+ from wintegrate.element import UiaElement
26
+ from wintegrate.window import Window
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AppSpec:
33
+ """Locale-independent identity of an application for launch-and-discover."""
34
+
35
+ name: str
36
+ command: tuple[str, ...]
37
+ process_names: tuple[str, ...] = ()
38
+ window_classes: tuple[str, ...] = ()
39
+ title_pattern: str | None = None
40
+ text_input_ladder: tuple[dict, ...] | None = None
41
+
42
+
43
+ NOTEPAD = AppSpec(
44
+ name="notepad",
45
+ command=("notepad.exe",),
46
+ process_names=("notepad.exe",),
47
+ window_classes=("Notepad",), # same class for classic and Store Notepad
48
+ )
49
+
50
+ CALCULATOR = AppSpec(
51
+ name="calculator",
52
+ command=("calc.exe",),
53
+ # Win11 WinUI / Win10 UWP / legacy Server builds
54
+ process_names=("calculatorapp.exe", "calculator.exe", "win32calc.exe"),
55
+ # UWP windows can be owned by ApplicationFrameHost rather than the app
56
+ # process, so keep a title fallback (localized variants live here, once).
57
+ title_pattern="(?i)calculator|小算盤|计算器|電卓",
58
+ )
59
+
60
+
61
+ class AppHandle:
62
+ """Owns one launched application: window + launcher process + guaranteed cleanup."""
63
+
64
+ def __init__(
65
+ self,
66
+ proc: subprocess.Popen | None,
67
+ window: Window,
68
+ spec: AppSpec | None = None,
69
+ ):
70
+ self.proc = proc
71
+ self.window = window
72
+ self.spec = spec
73
+
74
+ def find_text_input(self, timeout: float = 20.0) -> UiaElement:
75
+ ladder = self.spec.text_input_ladder if self.spec else None
76
+ return self.window.find_text_input(timeout=timeout, ladder=ladder)
77
+
78
+ def close(self) -> None:
79
+ try:
80
+ self.window.close(force=True)
81
+ except Exception as exc:
82
+ logger.debug(f"AppHandle window close failed ({type(exc).__name__}): {exc}")
83
+ if self.proc:
84
+ try:
85
+ self.proc.kill()
86
+ except Exception:
87
+ pass
88
+
89
+ def __enter__(self) -> AppHandle:
90
+ return self
91
+
92
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
93
+ self.close()
94
+ return False
95
+
96
+
97
+ def kill_processes(names: tuple[str, ...] | list[str]) -> None:
98
+ """Best-effort force-kill by image name (fresh-launch sweep for single-instance apps)."""
99
+ for name in names:
100
+ subprocess.run(["taskkill", "/F", "/IM", name], capture_output=True, check=False)
@@ -0,0 +1,287 @@
1
+ """CI-first diagnostic subsystem: streaming video recorder, desktop window census, and process tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import glob
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from PIL import Image
17
+
18
+ from wintegrate.interop import (
19
+ BITMAPINFOHEADER,
20
+ WNDENUMPROC,
21
+ attach_to_input_desktop,
22
+ gdi32,
23
+ get_input_desktop_handle,
24
+ get_window_class,
25
+ get_window_pid,
26
+ get_window_title,
27
+ user32,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def capture_screen_image() -> Image.Image:
34
+ """Captures the current primary display using GDI BitBlt into a PIL Image."""
35
+ w = user32.GetSystemMetrics(0)
36
+ h = user32.GetSystemMetrics(1)
37
+ hdc_screen = user32.GetDC(0)
38
+ hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
39
+ hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, w, h)
40
+ gdi32.SelectObject(hdc_mem, hbmp)
41
+ gdi32.BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, 0, 0, 0x00CC0020)
42
+
43
+ bmi = BITMAPINFOHEADER()
44
+ bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
45
+ bmi.biWidth = w
46
+ bmi.biHeight = -h # top-down DIB
47
+ bmi.biPlanes = 1
48
+ bmi.biBitCount = 32
49
+ bmi.biCompression = 0
50
+ buf = ctypes.create_string_buffer(w * h * 4)
51
+ gdi32.GetDIBits(hdc_mem, hbmp, 0, h, buf, ctypes.byref(bmi), 0)
52
+
53
+ gdi32.DeleteDC(hdc_mem)
54
+ user32.ReleaseDC(0, hdc_screen)
55
+ gdi32.DeleteObject(hbmp)
56
+
57
+ return Image.frombuffer("RGBA", (w, h), buf, "raw", "BGRA", 0, 1).convert("RGB")
58
+
59
+
60
+ def resolve_ffmpeg_exe() -> str | None:
61
+ """
62
+ Finds a verified, existing ffmpeg executable across x64 and ARM64.
63
+ Searches system PATH, standard Windows / Chocolatey / WinGet directories,
64
+ and imageio_ffmpeg.
65
+ """
66
+ # 1. System PATH
67
+ exe = shutil.which("ffmpeg")
68
+ if exe and os.path.isfile(exe):
69
+ return exe
70
+
71
+ # 2. Common Windows and Package Manager directories
72
+ common_patterns = [
73
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
74
+ r"C:\Program Files\FFmpeg\bin\ffmpeg.exe",
75
+ r"C:\ffmpeg\bin\ffmpeg.exe",
76
+ r"C:\tools\ffmpeg\bin\ffmpeg.exe",
77
+ r"C:\Users\*\AppData\Local\Microsoft\WinGet\Packages\*\*\bin\ffmpeg.exe",
78
+ r"C:\Users\*\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe",
79
+ ]
80
+ for pattern in common_patterns:
81
+ matches = glob.glob(pattern)
82
+ for m in matches:
83
+ if os.path.isfile(m):
84
+ return m
85
+
86
+ # 3. imageio_ffmpeg fallback (verify file exists)
87
+ try:
88
+ import imageio_ffmpeg
89
+
90
+ exe = imageio_ffmpeg.get_ffmpeg_exe()
91
+ if exe and os.path.isfile(exe):
92
+ return exe
93
+ except Exception as exc:
94
+ logger.debug(f"imageio_ffmpeg lookup failed ({type(exc).__name__}): {exc}")
95
+
96
+ return None
97
+
98
+
99
+ class ContinuousRecorder:
100
+ """
101
+ Low-memory streaming desktop screen recorder.
102
+ Pipes uncompressed frames directly to an FFmpeg subprocess via stdin.
103
+ Redirects FFmpeg stderr to a disk log file to prevent pipe buffer deadlocks.
104
+ """
105
+
106
+ def __init__(self, output_path: str | Path, fps: int = 30):
107
+ self.output_path = Path(output_path)
108
+ self.fps = fps
109
+ self.interval = 1.0 / fps
110
+ self.stop_event = threading.Event()
111
+ self.thread: threading.Thread | None = None
112
+ self._proc: subprocess.Popen | None = None
113
+ self._stderr_file = None
114
+ self._frame_count = 0
115
+ self._ffmpeg_exe = resolve_ffmpeg_exe()
116
+
117
+ def start(self) -> bool:
118
+ if not self._ffmpeg_exe:
119
+ logger.warning(
120
+ "No verified FFmpeg executable found on system. ContinuousRecorder video disabled."
121
+ )
122
+ return False
123
+
124
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
125
+ stderr_log_path = self.output_path.with_suffix(".ffmpeg.log")
126
+ self._stderr_file = open(stderr_log_path, "w", encoding="utf-8")
127
+
128
+ w = user32.GetSystemMetrics(0)
129
+ h = user32.GetSystemMetrics(1)
130
+
131
+ cmd = [
132
+ self._ffmpeg_exe,
133
+ "-y",
134
+ "-f",
135
+ "rawvideo",
136
+ "-vcodec",
137
+ "rawvideo",
138
+ "-s",
139
+ f"{w}x{h}",
140
+ "-pix_fmt",
141
+ "rgb24",
142
+ "-r",
143
+ str(self.fps),
144
+ "-i",
145
+ "-",
146
+ "-c:v",
147
+ "libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9",
148
+ "-pix_fmt",
149
+ "yuv420p",
150
+ str(self.output_path),
151
+ ]
152
+
153
+ try:
154
+ self._proc = subprocess.Popen(
155
+ cmd,
156
+ stdin=subprocess.PIPE,
157
+ stdout=subprocess.DEVNULL,
158
+ stderr=self._stderr_file,
159
+ )
160
+ except Exception as exc:
161
+ self._stderr_file.close()
162
+ logger.error(f"Failed to start FFmpeg subprocess ({type(exc).__name__}): {exc}")
163
+ return False
164
+
165
+ self.stop_event.clear()
166
+ self._frame_count = 0
167
+ self.thread = threading.Thread(target=self._record_loop, daemon=True)
168
+ self.thread.start()
169
+ logger.info(
170
+ f"ContinuousRecorder started (streaming to {self.output_path} @ {self.fps} FPS)"
171
+ )
172
+ return True
173
+
174
+ def _record_loop(self):
175
+ attach_to_input_desktop()
176
+ while not self.stop_event.is_set():
177
+ t0 = time.monotonic()
178
+ try:
179
+ img = capture_screen_image()
180
+ raw_bytes = img.tobytes()
181
+ if self._proc and self._proc.stdin:
182
+ self._proc.stdin.write(raw_bytes)
183
+ self._frame_count += 1
184
+ except (BrokenPipeError, OSError) as exc:
185
+ logger.error(f"FFmpeg stdin pipe broken ({type(exc).__name__}): {exc}")
186
+ break
187
+ except Exception as exc:
188
+ logger.debug(f"Screen capture frame skipped ({type(exc).__name__}): {exc}")
189
+
190
+ elapsed = time.monotonic() - t0
191
+ sleep_time = max(0.001, self.interval - elapsed)
192
+ time.sleep(sleep_time)
193
+
194
+ def stop(self, timeout: float = 5.0) -> int:
195
+ self.stop_event.set()
196
+ if self.thread:
197
+ self.thread.join(timeout=timeout)
198
+
199
+ if self._proc:
200
+ try:
201
+ if self._proc.stdin:
202
+ try:
203
+ self._proc.stdin.close()
204
+ except (OSError, BrokenPipeError):
205
+ pass
206
+ self._proc.wait(timeout=timeout)
207
+ except (subprocess.TimeoutExpired, OSError, BrokenPipeError):
208
+ try:
209
+ self._proc.kill()
210
+ except Exception:
211
+ pass
212
+ except Exception as exc:
213
+ logger.debug(
214
+ f"ContinuousRecorder process cleanup warning ({type(exc).__name__}): {exc}"
215
+ )
216
+ finally:
217
+ self._proc = None
218
+
219
+ if self._stderr_file:
220
+ try:
221
+ self._stderr_file.close()
222
+ except Exception:
223
+ pass
224
+ self._stderr_file = None
225
+
226
+ logger.info(f"ContinuousRecorder stopped. Total frames recorded: {self._frame_count}")
227
+ return self._frame_count
228
+
229
+
230
+ @dataclass
231
+ class WindowSnapshot:
232
+ hwnd: int
233
+ title: str
234
+ class_name: str
235
+ pid: int
236
+ is_visible: bool
237
+
238
+
239
+ @dataclass
240
+ class CensusDiff:
241
+ added: list[WindowSnapshot] = field(default_factory=list)
242
+ removed: list[WindowSnapshot] = field(default_factory=list)
243
+ persisted: list[WindowSnapshot] = field(default_factory=list)
244
+
245
+
246
+ class WindowCensus:
247
+ """Takes instant snapshots of all top-level desktop windows and diffs them."""
248
+
249
+ @staticmethod
250
+ def capture() -> list[WindowSnapshot]:
251
+ attach_to_input_desktop()
252
+ snapshots: list[WindowSnapshot] = []
253
+
254
+ def enum_proc(hwnd, _):
255
+ is_vis = bool(user32.IsWindowVisible(hwnd))
256
+ title = get_window_title(hwnd)
257
+ cls = get_window_class(hwnd)
258
+ pid = get_window_pid(hwnd)
259
+ snapshots.append(
260
+ WindowSnapshot(
261
+ hwnd=hwnd,
262
+ title=title,
263
+ class_name=cls,
264
+ pid=pid,
265
+ is_visible=is_vis,
266
+ )
267
+ )
268
+ return True
269
+
270
+ cb = WNDENUMPROC(enum_proc)
271
+ h_desk = get_input_desktop_handle()
272
+ if h_desk:
273
+ user32.EnumDesktopWindows(h_desk, cb, 0)
274
+ else:
275
+ user32.EnumWindows(cb, 0)
276
+ return snapshots
277
+
278
+ @classmethod
279
+ def diff(cls, before: list[WindowSnapshot], after: list[WindowSnapshot]) -> CensusDiff:
280
+ before_map = {w.hwnd: w for w in before}
281
+ after_map = {w.hwnd: w for w in after}
282
+
283
+ added = [w for hwnd, w in after_map.items() if hwnd not in before_map]
284
+ removed = [w for hwnd, w in before_map.items() if hwnd not in after_map]
285
+ persisted = [w for hwnd, w in after_map.items() if hwnd in before_map]
286
+
287
+ return CensusDiff(added=added, removed=removed, persisted=persisted)