window-grabber 0.1.0__py3-none-win32.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.
- window_grabber-0.1.0.data/purelib/window_capture/__init__.py +54 -0
- window_grabber-0.1.0.data/purelib/window_capture/__main__.py +120 -0
- window_grabber-0.1.0.data/purelib/window_capture/_api.py +359 -0
- window_grabber-0.1.0.data/purelib/window_capture/_comparison.py +117 -0
- window_grabber-0.1.0.data/purelib/window_capture/_deadline.py +25 -0
- window_grabber-0.1.0.data/purelib/window_capture/_discovery.py +83 -0
- window_grabber-0.1.0.data/purelib/window_capture/_modes.py +67 -0
- window_grabber-0.1.0.data/purelib/window_capture/_pool.py +67 -0
- window_grabber-0.1.0.data/purelib/window_capture/_protocol.py +109 -0
- window_grabber-0.1.0.data/purelib/window_capture/_response.py +61 -0
- window_grabber-0.1.0.data/purelib/window_capture/_transport.py +286 -0
- window_grabber-0.1.0.data/purelib/window_capture/bin/manifest.json +21 -0
- window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-hook-x86.dll +0 -0
- window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-legacy.exe +0 -0
- window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-native.exe +0 -0
- window_grabber-0.1.0.data/purelib/window_capture/events.py +63 -0
- window_grabber-0.1.0.data/purelib/window_capture/licenses/minhook.txt +81 -0
- window_grabber-0.1.0.data/purelib/window_capture/monitor.py +301 -0
- window_grabber-0.1.0.data/purelib/window_capture/options.py +394 -0
- window_grabber-0.1.0.data/purelib/window_capture/presentation.py +150 -0
- window_grabber-0.1.0.data/purelib/window_capture/py.typed +0 -0
- window_grabber-0.1.0.data/purelib/window_capture/recording.py +117 -0
- window_grabber-0.1.0.data/purelib/window_capture/results.py +186 -0
- window_grabber-0.1.0.dist-info/METADATA +401 -0
- window_grabber-0.1.0.dist-info/RECORD +28 -0
- window_grabber-0.1.0.dist-info/WHEEL +5 -0
- window_grabber-0.1.0.dist-info/entry_points.txt +2 -0
- window_grabber-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Window capture with a typed Python API and isolated, precompiled Rust workers."""
|
|
2
|
+
|
|
3
|
+
from ._comparison import ChangeOptions
|
|
4
|
+
from ._modes import recommended_capture_mode
|
|
5
|
+
from .monitor import ProcessMonitor, WindowEvent
|
|
6
|
+
from .recording import DirectoryRecorder, record_process
|
|
7
|
+
from .events import CaptureEvent, CaptureEvents
|
|
8
|
+
from ._api import CaptureSession, capture, find_windows, probe
|
|
9
|
+
from .options import (
|
|
10
|
+
CaptureOptions,
|
|
11
|
+
ContentOptions,
|
|
12
|
+
CoordinatorOptions,
|
|
13
|
+
DesktopOptions,
|
|
14
|
+
DiagnosticOptions,
|
|
15
|
+
ImageOptions,
|
|
16
|
+
PreparedSource,
|
|
17
|
+
Match,
|
|
18
|
+
Target,
|
|
19
|
+
ValidationOptions,
|
|
20
|
+
WorkerOptions,
|
|
21
|
+
)
|
|
22
|
+
from .results import CapabilityReport, CapturedImage, CaptureError, CaptureResult, CloseReport
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"recommended_capture_mode",
|
|
27
|
+
"DirectoryRecorder",
|
|
28
|
+
"record_process",
|
|
29
|
+
"ChangeOptions",
|
|
30
|
+
"ProcessMonitor",
|
|
31
|
+
"WindowEvent",
|
|
32
|
+
"CaptureEvent",
|
|
33
|
+
"CaptureEvents",
|
|
34
|
+
"capture",
|
|
35
|
+
"find_windows",
|
|
36
|
+
"probe",
|
|
37
|
+
"CaptureSession",
|
|
38
|
+
"Target",
|
|
39
|
+
"Match",
|
|
40
|
+
"PreparedSource",
|
|
41
|
+
"CaptureOptions",
|
|
42
|
+
"ContentOptions",
|
|
43
|
+
"DesktopOptions",
|
|
44
|
+
"DiagnosticOptions",
|
|
45
|
+
"ImageOptions",
|
|
46
|
+
"ValidationOptions",
|
|
47
|
+
"WorkerOptions",
|
|
48
|
+
"CoordinatorOptions",
|
|
49
|
+
"CapturedImage",
|
|
50
|
+
"CaptureResult",
|
|
51
|
+
"CaptureError",
|
|
52
|
+
"CapabilityReport",
|
|
53
|
+
"CloseReport",
|
|
54
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from . import CaptureOptions, CaptureSession, DesktopOptions, Target, capture, find_windows, probe
|
|
5
|
+
from .results import thaw
|
|
6
|
+
from .recording import DirectoryRecorder, record_process
|
|
7
|
+
from .monitor import ProcessMonitor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(description="Capture a window using the bundled Rust helper")
|
|
12
|
+
parser.add_argument("--monitor", action="store_true")
|
|
13
|
+
parser.add_argument("--interval-seconds", type=float, default=5.0)
|
|
14
|
+
parser.add_argument("--recording-max-bytes", type=int, default=1024 * 1024 * 1024)
|
|
15
|
+
parser.add_argument("--probe", action="store_true")
|
|
16
|
+
parser.add_argument("--find", action="store_true")
|
|
17
|
+
parser.add_argument("--hwnd", type=lambda x: int(x, 0))
|
|
18
|
+
parser.add_argument("--x11-window-id", type=lambda x: int(x, 0))
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--select-window", action="store_true", help="Select a Wayland window interactively"
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--pid", type=int)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--process-name", action="append", help="Repeat to monitor multiple executables"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--process-path")
|
|
27
|
+
parser.add_argument("--window-class")
|
|
28
|
+
parser.add_argument("--window-title")
|
|
29
|
+
parser.add_argument("--mode", choices=("isolated", "controlled_desktop"))
|
|
30
|
+
parser.add_argument("--allow-resize", action="store_true", default=None)
|
|
31
|
+
parser.add_argument("--desktop", action="store_true", help="Permit temporary desktop changes")
|
|
32
|
+
parser.add_argument("--separate-owned-popups", action="store_true")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--resize-to-fit", action="store_true", help="Permit temporary layout changes"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--timeout-ms", type=int, default=2000)
|
|
37
|
+
parser.add_argument("--output", default="capture.png")
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
if sum((args.monitor, args.probe, args.find, args.select_window)) > 1:
|
|
40
|
+
parser.error("Choose only one operation")
|
|
41
|
+
if args.mode and (args.desktop or args.separate_owned_popups or args.resize_to_fit):
|
|
42
|
+
parser.error("--mode replaces the advanced desktop flags; use --allow-resize")
|
|
43
|
+
if args.allow_resize and args.mode != "controlled_desktop":
|
|
44
|
+
parser.error("--allow-resize requires --mode controlled_desktop")
|
|
45
|
+
if args.process_name and len(args.process_name) > 1 and not args.monitor:
|
|
46
|
+
parser.error("Repeated --process-name requires --monitor")
|
|
47
|
+
if args.probe:
|
|
48
|
+
report = probe(timeout_ms=args.timeout_ms)
|
|
49
|
+
print(json.dumps(thaw(report.__dict__), indent=2))
|
|
50
|
+
return 0
|
|
51
|
+
options = CaptureOptions(
|
|
52
|
+
timeout_ms=args.timeout_ms,
|
|
53
|
+
desktop=DesktopOptions(
|
|
54
|
+
enabled=args.desktop,
|
|
55
|
+
separate_owned_popups=args.separate_owned_popups,
|
|
56
|
+
resize_to_fit=args.resize_to_fit,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
if args.select_window:
|
|
60
|
+
with CaptureSession(
|
|
61
|
+
options=options, mode=args.mode, allow_resize=args.allow_resize
|
|
62
|
+
) as session:
|
|
63
|
+
source = session.select_window()
|
|
64
|
+
result = session.capture(Target(portal_source=source))
|
|
65
|
+
print(json.dumps(result.metadata(), indent=2))
|
|
66
|
+
if result.accepted:
|
|
67
|
+
result.require_image().save(args.output)
|
|
68
|
+
return 0 if result.accepted else 1
|
|
69
|
+
targets = [
|
|
70
|
+
Target(
|
|
71
|
+
hwnd=args.hwnd,
|
|
72
|
+
x11_window_id=args.x11_window_id,
|
|
73
|
+
pid=args.pid,
|
|
74
|
+
process_name=name,
|
|
75
|
+
process_path=args.process_path,
|
|
76
|
+
window_class=args.window_class,
|
|
77
|
+
window_title=args.window_title,
|
|
78
|
+
)
|
|
79
|
+
for name in (args.process_name or [None])
|
|
80
|
+
]
|
|
81
|
+
target = targets[0]
|
|
82
|
+
if args.monitor:
|
|
83
|
+
if args.find or args.hwnd or args.x11_window_id:
|
|
84
|
+
parser.error("--monitor requires a process selector")
|
|
85
|
+
if args.output == "capture.png":
|
|
86
|
+
parser.error("--monitor requires --output DIRECTORY")
|
|
87
|
+
try:
|
|
88
|
+
with ProcessMonitor(
|
|
89
|
+
targets,
|
|
90
|
+
options=options,
|
|
91
|
+
allow_desktop_capture=args.desktop,
|
|
92
|
+
mode=args.mode,
|
|
93
|
+
allow_resize=args.allow_resize,
|
|
94
|
+
) as monitor:
|
|
95
|
+
record_process(
|
|
96
|
+
monitor,
|
|
97
|
+
DirectoryRecorder(args.output, max_bytes=args.recording_max_bytes),
|
|
98
|
+
interval_seconds=args.interval_seconds,
|
|
99
|
+
discovery_timeout_ms=args.timeout_ms,
|
|
100
|
+
)
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
return 0
|
|
103
|
+
return 0
|
|
104
|
+
if args.find:
|
|
105
|
+
print(json.dumps(thaw(find_windows(target, timeout_ms=args.timeout_ms)), indent=2))
|
|
106
|
+
return 0
|
|
107
|
+
result = capture(
|
|
108
|
+
target,
|
|
109
|
+
options=options,
|
|
110
|
+
mode=args.mode,
|
|
111
|
+
allow_resize=args.allow_resize,
|
|
112
|
+
)
|
|
113
|
+
print(json.dumps(result.metadata(), indent=2))
|
|
114
|
+
if result.accepted:
|
|
115
|
+
result.require_image().save(args.output)
|
|
116
|
+
return 0 if result.accepted else 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Synchronous Python facade over the precompiled Rust coordinator protocol."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ._response import capture_result
|
|
11
|
+
from ._discovery import identity as window_identity
|
|
12
|
+
from ._modes import options_for_mode
|
|
13
|
+
from .events import CaptureEvents
|
|
14
|
+
from ._pool import NativePool
|
|
15
|
+
from . import _transport
|
|
16
|
+
from ._deadline import Failure
|
|
17
|
+
from .options import (
|
|
18
|
+
BACKENDS,
|
|
19
|
+
CaptureOptions,
|
|
20
|
+
CoordinatorOptions,
|
|
21
|
+
PreparedSource,
|
|
22
|
+
Target,
|
|
23
|
+
WorkerOptions,
|
|
24
|
+
integer,
|
|
25
|
+
to_wire,
|
|
26
|
+
)
|
|
27
|
+
from .results import (
|
|
28
|
+
CapabilityReport,
|
|
29
|
+
CaptureError,
|
|
30
|
+
CaptureResult,
|
|
31
|
+
CloseReport,
|
|
32
|
+
freeze,
|
|
33
|
+
validate_metadata,
|
|
34
|
+
validate_capabilities,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CaptureSession:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
options: Optional[CaptureOptions] = None,
|
|
43
|
+
workers: Optional[WorkerOptions] = None,
|
|
44
|
+
coordinator: Optional[CoordinatorOptions] = None,
|
|
45
|
+
event_capacity: int = 128,
|
|
46
|
+
mode: Optional[str] = None,
|
|
47
|
+
allow_resize: Optional[bool] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._pid = os.getpid()
|
|
50
|
+
self.options = options_for_mode(options, mode, allow_resize)
|
|
51
|
+
self._mode = mode
|
|
52
|
+
self._allow_resize = allow_resize
|
|
53
|
+
self.workers = WorkerOptions() if workers is None else workers
|
|
54
|
+
if not isinstance(self.options, CaptureOptions) or not isinstance(
|
|
55
|
+
self.workers, WorkerOptions
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("options and workers require complete typed option objects")
|
|
58
|
+
if coordinator is not None and not isinstance(coordinator, CoordinatorOptions):
|
|
59
|
+
raise ValueError("coordinator requires CoordinatorOptions")
|
|
60
|
+
self.coordinator = coordinator
|
|
61
|
+
self.events = CaptureEvents(event_capacity)
|
|
62
|
+
# Slots are released by the I/O worker, not by a timed-out caller.
|
|
63
|
+
self._pool = NativePool(self.workers.idle_timeout_ms)
|
|
64
|
+
self._slots = threading.BoundedSemaphore(self.workers.max_workers)
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
self._drained = threading.Condition(self._lock)
|
|
67
|
+
self._tickets = set()
|
|
68
|
+
self._closed = False
|
|
69
|
+
self._recovery = "not_needed"
|
|
70
|
+
|
|
71
|
+
def _check_process(self):
|
|
72
|
+
if self._pid != os.getpid():
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
"Create a new CaptureSession after fork; inherited sessions cannot be used"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def __enter__(self):
|
|
78
|
+
self._check_process()
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, *args):
|
|
82
|
+
self.close(timeout_ms=0)
|
|
83
|
+
|
|
84
|
+
def _request(self, operation, start, timeout_ms, acquire_ms=None, *, _portal=False, **values):
|
|
85
|
+
self._check_process()
|
|
86
|
+
integer("timeout_ms", timeout_ms, 1, 86400000)
|
|
87
|
+
end = start + timeout_ms / 1000.0
|
|
88
|
+
with self._lock:
|
|
89
|
+
if self._closed:
|
|
90
|
+
raise Failure("CANCELLED", "Capture session is closed", "CANCELLED")
|
|
91
|
+
if not self._slots.acquire(blocking=False):
|
|
92
|
+
raise Failure("RESOURCE_LIMIT", "Session worker limit reached")
|
|
93
|
+
ticket = None
|
|
94
|
+
released = False
|
|
95
|
+
|
|
96
|
+
def done():
|
|
97
|
+
nonlocal released
|
|
98
|
+
with self._lock:
|
|
99
|
+
if released:
|
|
100
|
+
return
|
|
101
|
+
released = True
|
|
102
|
+
self._tickets.discard(ticket)
|
|
103
|
+
self._slots.release()
|
|
104
|
+
self._drained.notify_all()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
portal = (
|
|
108
|
+
_portal
|
|
109
|
+
or operation == "select"
|
|
110
|
+
or values.get("target", {}).get("portal_source") is not None
|
|
111
|
+
)
|
|
112
|
+
command = _transport.portal_command() if portal else _transport.installed_command()
|
|
113
|
+
request = dict(
|
|
114
|
+
values,
|
|
115
|
+
protocol_version=1,
|
|
116
|
+
operation=operation,
|
|
117
|
+
workers=to_wire(self.workers),
|
|
118
|
+
coordinator=to_wire(self.coordinator),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
ticket = _transport.Ticket(
|
|
122
|
+
command,
|
|
123
|
+
request,
|
|
124
|
+
end,
|
|
125
|
+
start + (acquire_ms if acquire_ms is not None else timeout_ms) / 1000.0,
|
|
126
|
+
self.workers.max_buffer_bytes,
|
|
127
|
+
values.get("options", {}).get("image", {}).get("max_pixels", 40000000),
|
|
128
|
+
done,
|
|
129
|
+
self._pool,
|
|
130
|
+
)
|
|
131
|
+
with self._lock:
|
|
132
|
+
if self._closed:
|
|
133
|
+
ticket.cancelled.set()
|
|
134
|
+
self._tickets.add(ticket)
|
|
135
|
+
_transport.broker().submit(ticket)
|
|
136
|
+
except BaseException as exc:
|
|
137
|
+
done()
|
|
138
|
+
if isinstance(exc, (RuntimeError, OSError)):
|
|
139
|
+
raise Failure("WORKER_START_FAILED", "Native broker could not start") from None
|
|
140
|
+
raise
|
|
141
|
+
try:
|
|
142
|
+
reply = ticket.answer.get(timeout=max(0, end - time.monotonic()))
|
|
143
|
+
except queue.Empty:
|
|
144
|
+
ticket.cancelled.set()
|
|
145
|
+
raise Failure("DEADLINE_EXCEEDED") from None
|
|
146
|
+
except BaseException:
|
|
147
|
+
ticket.cancelled.set()
|
|
148
|
+
raise
|
|
149
|
+
if isinstance(reply, Failure):
|
|
150
|
+
raise reply
|
|
151
|
+
if time.monotonic() >= end:
|
|
152
|
+
raise Failure("DEADLINE_EXCEEDED")
|
|
153
|
+
meta, payload = reply
|
|
154
|
+
if meta.get("protocol_version") != 1 or meta.get("request_id") != values.get("request_id"):
|
|
155
|
+
raise Failure("INVALID_BUFFER", "Native response identity/version mismatch")
|
|
156
|
+
if meta.get("status") == "FAILED":
|
|
157
|
+
try:
|
|
158
|
+
validate_metadata(meta)
|
|
159
|
+
except ValueError:
|
|
160
|
+
raise Failure("INVALID_BUFFER", "Malformed native failure response") from None
|
|
161
|
+
if operation in ("find", "probe") and payload:
|
|
162
|
+
raise Failure("INVALID_BUFFER", "Unexpected discovery or capability payload")
|
|
163
|
+
return meta, payload
|
|
164
|
+
|
|
165
|
+
def capture(self, target: Target, *, options: Optional[CaptureOptions] = None) -> CaptureResult:
|
|
166
|
+
self._check_process()
|
|
167
|
+
start = time.monotonic()
|
|
168
|
+
if not isinstance(target, Target):
|
|
169
|
+
raise ValueError("target requires Target")
|
|
170
|
+
effective = (
|
|
171
|
+
self.options
|
|
172
|
+
if options is None
|
|
173
|
+
else options_for_mode(options, self._mode, self._allow_resize)
|
|
174
|
+
)
|
|
175
|
+
if not isinstance(effective, CaptureOptions):
|
|
176
|
+
raise ValueError("options requires a complete CaptureOptions")
|
|
177
|
+
request_id = uuid.uuid4().hex
|
|
178
|
+
try:
|
|
179
|
+
meta, payload = self._request(
|
|
180
|
+
"capture",
|
|
181
|
+
start,
|
|
182
|
+
effective.timeout_ms,
|
|
183
|
+
effective.effective_acquire_ms,
|
|
184
|
+
target=to_wire(target),
|
|
185
|
+
options=to_wire(effective),
|
|
186
|
+
request_id=request_id,
|
|
187
|
+
)
|
|
188
|
+
result = capture_result(meta, payload, effective)
|
|
189
|
+
if time.monotonic() >= start + effective.timeout_ms / 1000.0:
|
|
190
|
+
raise Failure("DEADLINE_EXCEEDED")
|
|
191
|
+
except (Failure, ValueError, TypeError, KeyError) as exc:
|
|
192
|
+
failure = (
|
|
193
|
+
exc
|
|
194
|
+
if isinstance(exc, Failure)
|
|
195
|
+
else Failure("INVALID_BUFFER", "Malformed capture response")
|
|
196
|
+
)
|
|
197
|
+
uncertain = effective.desktop.enabled and failure.reason in (
|
|
198
|
+
"DEADLINE_EXCEEDED",
|
|
199
|
+
"WORKER_EXITED",
|
|
200
|
+
"INVALID_BUFFER",
|
|
201
|
+
)
|
|
202
|
+
result = CaptureResult(
|
|
203
|
+
request_id,
|
|
204
|
+
failure.status,
|
|
205
|
+
failure.reason,
|
|
206
|
+
failure.message,
|
|
207
|
+
recovery="pending" if uncertain else "not_needed",
|
|
208
|
+
)
|
|
209
|
+
from dataclasses import replace
|
|
210
|
+
|
|
211
|
+
timing = dict(result.timing, total_ms=(time.monotonic() - start) * 1000)
|
|
212
|
+
result = replace(result, timing=timing)
|
|
213
|
+
if result.recovery in ("pending", "failed"):
|
|
214
|
+
with self._lock:
|
|
215
|
+
self._recovery = result.recovery
|
|
216
|
+
self.events._record(result)
|
|
217
|
+
if not result.accepted and effective.on_failure == "raise":
|
|
218
|
+
raise CaptureError(result)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def find_windows(
|
|
222
|
+
self, target: Target, *, timeout_ms: int = 2000
|
|
223
|
+
) -> Tuple[Mapping[str, Any], ...]:
|
|
224
|
+
integer("timeout_ms", timeout_ms)
|
|
225
|
+
if not isinstance(target, Target):
|
|
226
|
+
raise ValueError("target requires Target")
|
|
227
|
+
request_id = uuid.uuid4().hex
|
|
228
|
+
try:
|
|
229
|
+
meta, _ = self._request(
|
|
230
|
+
"find", time.monotonic(), timeout_ms, target=to_wire(target), request_id=request_id
|
|
231
|
+
)
|
|
232
|
+
if meta.get("status") == "FAILED":
|
|
233
|
+
raise Failure(meta["reason"], meta.get("message", ""))
|
|
234
|
+
windows = meta["windows"]
|
|
235
|
+
if not isinstance(windows, (list, tuple)) or any(
|
|
236
|
+
not isinstance(window, Mapping) for window in windows
|
|
237
|
+
):
|
|
238
|
+
raise ValueError("Invalid window list")
|
|
239
|
+
for window in windows:
|
|
240
|
+
window_identity(window)
|
|
241
|
+
return tuple(freeze(window) for window in windows)
|
|
242
|
+
except (Failure, KeyError, TypeError, ValueError) as exc:
|
|
243
|
+
if not isinstance(exc, Failure):
|
|
244
|
+
exc = Failure("INVALID_BUFFER", "Malformed window discovery response")
|
|
245
|
+
raise CaptureError(
|
|
246
|
+
CaptureResult(request_id, "FAILED", exc.reason, exc.message)
|
|
247
|
+
) from None
|
|
248
|
+
|
|
249
|
+
def prepare(self, *, timeout_ms: int = 5000) -> CapabilityReport:
|
|
250
|
+
return self._probe(timeout_ms)
|
|
251
|
+
|
|
252
|
+
def _probe(self, timeout_ms):
|
|
253
|
+
integer("timeout_ms", timeout_ms)
|
|
254
|
+
start = time.monotonic()
|
|
255
|
+
try:
|
|
256
|
+
meta, _ = self._request("probe", start, timeout_ms, request_id=uuid.uuid4().hex)
|
|
257
|
+
if meta.get("status") == "FAILED":
|
|
258
|
+
raise Failure(meta["reason"], meta.get("message", ""))
|
|
259
|
+
validate_capabilities(meta["backends"], meta.get("environment", {}))
|
|
260
|
+
# Optional Linux libraries live in a separate executable. Probe it
|
|
261
|
+
# under the original deadline and never open a permission dialog.
|
|
262
|
+
try:
|
|
263
|
+
_transport.portal_command()
|
|
264
|
+
except Failure:
|
|
265
|
+
pass
|
|
266
|
+
else:
|
|
267
|
+
try:
|
|
268
|
+
portal, _ = self._request(
|
|
269
|
+
"probe", start, timeout_ms, _portal=True, request_id=uuid.uuid4().hex
|
|
270
|
+
)
|
|
271
|
+
descriptor = portal["backends"]["wayland_portal"]
|
|
272
|
+
validate_capabilities({"wayland_portal": descriptor}, {})
|
|
273
|
+
meta["backends"]["wayland_portal"] = descriptor
|
|
274
|
+
except (Failure, KeyError, TypeError, ValueError) as exc:
|
|
275
|
+
meta["backends"]["wayland_portal"] = {
|
|
276
|
+
"available": False,
|
|
277
|
+
"reason": exc.reason if isinstance(exc, Failure) else "INVALID_BUFFER",
|
|
278
|
+
}
|
|
279
|
+
return CapabilityReport(meta["backends"], meta.get("environment", {}))
|
|
280
|
+
except (Failure, KeyError, TypeError, ValueError) as exc:
|
|
281
|
+
if not isinstance(exc, Failure):
|
|
282
|
+
exc = Failure("INVALID_BUFFER", "Malformed capability response")
|
|
283
|
+
return CapabilityReport(
|
|
284
|
+
{b: {"available": False, "reason": exc.reason} for b in BACKENDS},
|
|
285
|
+
{"reason": exc.reason, "message": exc.message},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def select_window(self, *, timeout_ms: Optional[int] = None) -> PreparedSource:
|
|
289
|
+
if timeout_ms is not None:
|
|
290
|
+
integer("timeout_ms", timeout_ms, 1, 86400000)
|
|
291
|
+
request_id = uuid.uuid4().hex
|
|
292
|
+
try:
|
|
293
|
+
meta, _ = self._request(
|
|
294
|
+
"select",
|
|
295
|
+
time.monotonic(),
|
|
296
|
+
timeout_ms or 86400000,
|
|
297
|
+
request_id=request_id,
|
|
298
|
+
options=to_wire(self.options),
|
|
299
|
+
)
|
|
300
|
+
if meta.get("status") == "FAILED":
|
|
301
|
+
raise Failure(meta["reason"], meta.get("message", ""))
|
|
302
|
+
return PreparedSource(**meta["source"])
|
|
303
|
+
except (Failure, ValueError, TypeError, KeyError) as exc:
|
|
304
|
+
failure = (
|
|
305
|
+
exc
|
|
306
|
+
if isinstance(exc, Failure)
|
|
307
|
+
else Failure("INVALID_BUFFER", "Invalid source response")
|
|
308
|
+
)
|
|
309
|
+
raise CaptureError(
|
|
310
|
+
CaptureResult(request_id, "FAILED", failure.reason, failure.message)
|
|
311
|
+
) from None
|
|
312
|
+
|
|
313
|
+
def close(self, *, timeout_ms: int = 1000) -> CloseReport:
|
|
314
|
+
self._check_process()
|
|
315
|
+
integer("timeout_ms", timeout_ms, 0, 86400000)
|
|
316
|
+
end = time.monotonic() + timeout_ms / 1000.0
|
|
317
|
+
with self._drained:
|
|
318
|
+
self._closed = True
|
|
319
|
+
self._pool.close()
|
|
320
|
+
for ticket in self._tickets:
|
|
321
|
+
ticket.cancelled.set()
|
|
322
|
+
if ticket.request.get("options", {}).get("desktop", {}).get("enabled"):
|
|
323
|
+
self._recovery = "pending"
|
|
324
|
+
# Watchdog owns teardown. A bounded wait releases the session lock.
|
|
325
|
+
while self._tickets:
|
|
326
|
+
remaining = end - time.monotonic()
|
|
327
|
+
if remaining <= 0:
|
|
328
|
+
break
|
|
329
|
+
self._drained.wait(timeout=remaining)
|
|
330
|
+
return CloseReport(True, self._recovery)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def capture(
|
|
334
|
+
target: Target,
|
|
335
|
+
*,
|
|
336
|
+
options: Optional[CaptureOptions] = None,
|
|
337
|
+
mode: Optional[str] = None,
|
|
338
|
+
allow_resize: Optional[bool] = None,
|
|
339
|
+
) -> CaptureResult:
|
|
340
|
+
with CaptureSession(options=options, mode=mode, allow_resize=allow_resize) as session:
|
|
341
|
+
return session.capture(target)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def find_windows(target: Target, *, timeout_ms: int = 2000) -> Tuple[Mapping[str, Any], ...]:
|
|
345
|
+
with CaptureSession() as session:
|
|
346
|
+
return session.find_windows(target, timeout_ms=timeout_ms)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def probe(*, timeout_ms: int = 2000, check_workers: bool = True) -> CapabilityReport:
|
|
350
|
+
integer("timeout_ms", timeout_ms)
|
|
351
|
+
if type(check_workers) is not bool:
|
|
352
|
+
raise ValueError("check_workers must be a bool")
|
|
353
|
+
if not check_workers:
|
|
354
|
+
return CapabilityReport({b: {"available": None, "reason": "NOT_PROBED"} for b in BACKENDS})
|
|
355
|
+
with CaptureSession() as session:
|
|
356
|
+
return session._probe(timeout_ms)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_capture_result = capture_result
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Bounded decoding of our native PNG output and configurable pixel comparison."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import struct
|
|
5
|
+
import zlib
|
|
6
|
+
|
|
7
|
+
from .options import integer
|
|
8
|
+
from .results import CapturedImage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ChangeOptions:
|
|
13
|
+
"""A pixel counts when any RGBA channel differs by more than the tolerance."""
|
|
14
|
+
|
|
15
|
+
channel_tolerance: int = 3
|
|
16
|
+
min_changed_pixels: int = 1
|
|
17
|
+
|
|
18
|
+
def __post_init__(self):
|
|
19
|
+
integer("channel_tolerance", self.channel_tolerance, 0, 255)
|
|
20
|
+
integer("min_changed_pixels", self.min_changed_pixels)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Baseline:
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
color_space: str
|
|
28
|
+
alpha: str
|
|
29
|
+
pixels: bytes = field(repr=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode(image: CapturedImage) -> Baseline:
|
|
33
|
+
integer("image.width", image.width, 1, 0xFFFFFFFF)
|
|
34
|
+
integer("image.height", image.height, 1, 0xFFFFFFFF)
|
|
35
|
+
length = image.width * image.height * 4
|
|
36
|
+
if image.format == "rgba":
|
|
37
|
+
if len(image.data) != length:
|
|
38
|
+
raise ValueError("RGBA dimensions do not match pixels")
|
|
39
|
+
pixels = image.data
|
|
40
|
+
elif image.format == "png":
|
|
41
|
+
data = image.data
|
|
42
|
+
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
43
|
+
raise ValueError("Invalid PNG signature")
|
|
44
|
+
offset, compressed, header, ended = 8, bytearray(), False, False
|
|
45
|
+
while offset + 12 <= len(data):
|
|
46
|
+
size = struct.unpack_from(">I", data, offset)[0]
|
|
47
|
+
kind = data[offset + 4 : offset + 8]
|
|
48
|
+
stop = offset + 8 + size
|
|
49
|
+
if stop + 4 > len(data):
|
|
50
|
+
raise ValueError("Truncated PNG chunk")
|
|
51
|
+
body = data[offset + 8 : stop]
|
|
52
|
+
if zlib.crc32(kind + body) & 0xFFFFFFFF != struct.unpack_from(">I", data, stop)[0]:
|
|
53
|
+
raise ValueError("PNG checksum mismatch")
|
|
54
|
+
if not header:
|
|
55
|
+
expected = struct.pack(">IIBBBBB", image.width, image.height, 8, 6, 0, 0, 0)
|
|
56
|
+
if kind != b"IHDR" or body != expected:
|
|
57
|
+
raise ValueError("Expected native RGBA8 PNG")
|
|
58
|
+
header = True
|
|
59
|
+
elif kind == b"IDAT":
|
|
60
|
+
compressed.extend(body)
|
|
61
|
+
elif kind == b"IEND":
|
|
62
|
+
if body or stop + 4 != len(data):
|
|
63
|
+
raise ValueError("Invalid PNG ending")
|
|
64
|
+
ended = True
|
|
65
|
+
break
|
|
66
|
+
elif kind != b"sRGB" or body != b"\x00":
|
|
67
|
+
raise ValueError("Unexpected native PNG chunk")
|
|
68
|
+
offset = stop + 4
|
|
69
|
+
if not ended:
|
|
70
|
+
raise ValueError("Missing PNG ending")
|
|
71
|
+
decoder = zlib.decompressobj()
|
|
72
|
+
row_size = image.width * 4 + 1
|
|
73
|
+
expected_size = row_size * image.height
|
|
74
|
+
try:
|
|
75
|
+
raw = decoder.decompress(compressed, expected_size + 1)
|
|
76
|
+
except zlib.error as error:
|
|
77
|
+
raise ValueError("Invalid PNG compressed pixels") from error
|
|
78
|
+
if (
|
|
79
|
+
len(raw) != expected_size
|
|
80
|
+
or not decoder.eof
|
|
81
|
+
or decoder.unused_data
|
|
82
|
+
or decoder.unconsumed_tail
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("PNG decompression exceeds dimensions or is incomplete")
|
|
85
|
+
# The native encoder deliberately emits unfiltered rows. Reject a new
|
|
86
|
+
# encoding contract instead of interpreting filtered bytes as pixels.
|
|
87
|
+
if any(raw[y * row_size] != 0 for y in range(image.height)):
|
|
88
|
+
raise ValueError("Unsupported native PNG row filter")
|
|
89
|
+
pixels = b"".join(raw[y * row_size + 1 : (y + 1) * row_size] for y in range(image.height))
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError("Unsupported image format")
|
|
92
|
+
return Baseline(image.width, image.height, image.color_space, image.alpha, pixels)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def changed(previous: Baseline, current: Baseline, options: ChangeOptions) -> bool:
|
|
96
|
+
if (previous.width, previous.height, previous.color_space, previous.alpha) != (
|
|
97
|
+
current.width,
|
|
98
|
+
current.height,
|
|
99
|
+
current.color_space,
|
|
100
|
+
current.alpha,
|
|
101
|
+
):
|
|
102
|
+
return True
|
|
103
|
+
if previous.pixels == current.pixels:
|
|
104
|
+
return False
|
|
105
|
+
count = 0
|
|
106
|
+
# Compare packed pixels first; identical pixels need no Python channel loop.
|
|
107
|
+
before = memoryview(previous.pixels).cast("I")
|
|
108
|
+
after = memoryview(current.pixels).cast("I")
|
|
109
|
+
for a, b in zip(before, after):
|
|
110
|
+
if a != b and any(
|
|
111
|
+
abs(((a >> shift) & 255) - ((b >> shift) & 255)) > options.channel_tolerance
|
|
112
|
+
for shift in (0, 8, 16, 24)
|
|
113
|
+
):
|
|
114
|
+
count += 1
|
|
115
|
+
if count >= options.min_changed_pixels:
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Failure(Exception):
|
|
5
|
+
def __init__(self, reason, message="", status="FAILED"):
|
|
6
|
+
self.reason = reason
|
|
7
|
+
self.message = message or reason.replace("_", " ").lower()
|
|
8
|
+
self.status = status
|
|
9
|
+
super().__init__(self.message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Deadline:
|
|
13
|
+
def __init__(self, end, clock=time.monotonic):
|
|
14
|
+
self.end = end
|
|
15
|
+
self.clock = clock
|
|
16
|
+
|
|
17
|
+
def remaining(self):
|
|
18
|
+
return max(0.0, self.end - self.clock())
|
|
19
|
+
|
|
20
|
+
def check(self, reason="DEADLINE_EXCEEDED"):
|
|
21
|
+
if self.remaining() <= 0:
|
|
22
|
+
raise Failure(reason)
|
|
23
|
+
|
|
24
|
+
def cap(self, milliseconds):
|
|
25
|
+
return Deadline(min(self.end, self.clock() + milliseconds / 1000), self.clock)
|