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 +76 -0
- wintegrate/apps.py +100 -0
- wintegrate/diagnostics.py +287 -0
- wintegrate/element.py +529 -0
- wintegrate/env.py +105 -0
- wintegrate/exceptions.py +43 -0
- wintegrate/interop.py +395 -0
- wintegrate/recorder.py +234 -0
- wintegrate/session.py +468 -0
- wintegrate/text.py +36 -0
- wintegrate/window.py +281 -0
- wintegrate-0.1.0a1.dist-info/METADATA +197 -0
- wintegrate-0.1.0a1.dist-info/RECORD +15 -0
- wintegrate-0.1.0a1.dist-info/WHEEL +4 -0
- wintegrate-0.1.0a1.dist-info/licenses/LICENSE +21 -0
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)
|