python-libei 0.7.0__py3-none-any.whl → 0.8.1__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.
- libei/__init__.py +1 -1
- libei/_capi/libei.py +7 -1
- libei/ei.py +5 -3
- libei/eis.py +21 -5
- libei/portal.py +357 -36
- {python_libei-0.7.0.dist-info → python_libei-0.8.1.dist-info}/METADATA +17 -7
- python_libei-0.8.1.dist-info/RECORD +17 -0
- {python_libei-0.7.0.dist-info → python_libei-0.8.1.dist-info}/WHEEL +1 -1
- python_libei-0.7.0.dist-info/RECORD +0 -17
- {python_libei-0.7.0.dist-info → python_libei-0.8.1.dist-info}/licenses/LICENSE +0 -0
- {python_libei-0.7.0.dist-info → python_libei-0.8.1.dist-info}/top_level.txt +0 -0
libei/__init__.py
CHANGED
libei/_capi/libei.py
CHANGED
|
@@ -219,7 +219,13 @@ region_get_mapping_id = lib.function( # libei 1.1+
|
|
|
219
219
|
"ei_region_get_mapping_id", (c_void_p,), c_char_p
|
|
220
220
|
)
|
|
221
221
|
# x and y are in/out parameters, overwritten only when the point is inside.
|
|
222
|
-
|
|
222
|
+
# No version marker: this one is in libei 1.0.0's own public header and
|
|
223
|
+
# implemented there, so every libei at or above the declared floor exports
|
|
224
|
+
# it. It carried `# libei 1.1+` alongside its genuine 1.1 neighbours
|
|
225
|
+
# `ei_region_get_mapping_id` and `ei_device_region_at`, and the weekly
|
|
226
|
+
# floor job caught it -- building 1.0.0 from source found the symbol
|
|
227
|
+
# present where the marker promised it would be absent.
|
|
228
|
+
region_convert_point = lib.function(
|
|
223
229
|
"ei_region_convert_point",
|
|
224
230
|
(c_void_p, POINTER(c_double), POINTER(c_double)),
|
|
225
231
|
c_bool,
|
libei/ei.py
CHANGED
|
@@ -372,8 +372,7 @@ class Region(CObject):
|
|
|
372
372
|
|
|
373
373
|
Returns the point with the region's offset subtracted, or ``None``
|
|
374
374
|
if it falls outside the region -- which is also how you test
|
|
375
|
-
membership without a second :meth:`contains` call.
|
|
376
|
-
libei 1.1.
|
|
375
|
+
membership without a second :meth:`contains` call.
|
|
377
376
|
"""
|
|
378
377
|
# x/y are in-out parameters: libei overwrites them only when the
|
|
379
378
|
# point is inside, so the return value has to gate reading them.
|
|
@@ -1127,7 +1126,10 @@ class Context(CObject):
|
|
|
1127
1126
|
pointer = _capi.libei.get_event(self)
|
|
1128
1127
|
if not pointer:
|
|
1129
1128
|
break
|
|
1130
|
-
|
|
1129
|
+
# adopt(), not wrap(): ei_get_event() transfers ownership of
|
|
1130
|
+
# the returned reference to the caller, and the unref in
|
|
1131
|
+
# release() below is the one that balances it.
|
|
1132
|
+
event = Event.adopt(pointer)
|
|
1131
1133
|
assert event is not None
|
|
1132
1134
|
# try/finally, not a bare call after yield: breaking out of a
|
|
1133
1135
|
# `for event in ctx.events:` loop (or an exception propagating
|
libei/eis.py
CHANGED
|
@@ -944,7 +944,18 @@ class Event(CObject):
|
|
|
944
944
|
|
|
945
945
|
@property
|
|
946
946
|
def emulating_sequence(self) -> int:
|
|
947
|
-
"""Sequence number of the start_emulating transaction.
|
|
947
|
+
"""Sequence number of the start_emulating transaction.
|
|
948
|
+
|
|
949
|
+
Reads 0 on libei older than 1.6, whatever the client sent. libeis
|
|
950
|
+
queued the event without ever copying the sequence into it, so the
|
|
951
|
+
getter returned a zeroed struct field; upstream added the missing
|
|
952
|
+
assignment in libei 1.6.0. Every version from this package's 1.0.0
|
|
953
|
+
floor up exports the symbol, so there is nothing to raise and no
|
|
954
|
+
way to tell an old library apart from a client that genuinely sent
|
|
955
|
+
0 -- treat a 0 here as "unknown" unless you know both ends are on
|
|
956
|
+
1.6. :attr:`libei.ei.Event.emulating_sequence`, the receiver side
|
|
957
|
+
of the same transaction, is unaffected: libei has always stored it.
|
|
958
|
+
"""
|
|
948
959
|
self._require("emulating_sequence", EventType.DEVICE_START_EMULATING)
|
|
949
960
|
return _capi.libeis.event_emulating_get_sequence(self)
|
|
950
961
|
|
|
@@ -1150,7 +1161,10 @@ class Eis(CObject):
|
|
|
1150
1161
|
pointer = _capi.libeis.get_event(self)
|
|
1151
1162
|
if not pointer:
|
|
1152
1163
|
break
|
|
1153
|
-
|
|
1164
|
+
# adopt(), not wrap(): eis_get_event() transfers ownership of
|
|
1165
|
+
# the returned reference to the caller, and the unref in
|
|
1166
|
+
# release() below is the one that balances it.
|
|
1167
|
+
event = Event.adopt(pointer)
|
|
1154
1168
|
assert event is not None
|
|
1155
1169
|
# See ei.Context.events: try/finally so release() still runs
|
|
1156
1170
|
# if the caller breaks out of the loop (GeneratorExit at the
|
|
@@ -1168,9 +1182,11 @@ class Eis(CObject):
|
|
|
1168
1182
|
def set_flag(self, flag: Flag) -> None:
|
|
1169
1183
|
"""Change this context's protocol behavior. Requires libei 1.6.
|
|
1170
1184
|
|
|
1171
|
-
Must be called before the backend is set up, so
|
|
1172
|
-
|
|
1173
|
-
|
|
1185
|
+
Must be called before the backend is set up, so the supported
|
|
1186
|
+
way to use it is the ``flags`` parameter of
|
|
1187
|
+
:meth:`create_for_fd` / :meth:`create_for_socket`, which apply
|
|
1188
|
+
each flag at exactly that point; calling it directly only works
|
|
1189
|
+
on a context built by hand, before its backend is set up.
|
|
1174
1190
|
Takes one flag, never a bitmask; call it again for another.
|
|
1175
1191
|
"""
|
|
1176
1192
|
result = _capi.libeis.set_flag(self, flag)
|
libei/portal.py
CHANGED
|
@@ -90,6 +90,8 @@ __all__ = [
|
|
|
90
90
|
"PortalVersionError",
|
|
91
91
|
"PortalDeniedError",
|
|
92
92
|
"PortalTimeoutError",
|
|
93
|
+
"PortalSessionClosedError",
|
|
94
|
+
"PortalProtocolError",
|
|
93
95
|
"RemoteDesktopSession",
|
|
94
96
|
"Activation",
|
|
95
97
|
"InputCaptureSession",
|
|
@@ -185,6 +187,53 @@ class PortalDeniedError(PortalError):
|
|
|
185
187
|
self.message = message
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
class PortalProtocolError(PortalError):
|
|
191
|
+
"""A portal signal arrived with a payload that does not match the spec.
|
|
192
|
+
|
|
193
|
+
Every signal :func:`_wait_for_signal` waits on is specified as
|
|
194
|
+
``(o session_handle, a{sv} options)``, and two kinds of mismatch used
|
|
195
|
+
to fail in two different, equally unhelpful ways. The *shape* being
|
|
196
|
+
wrong -- an empty tuple, a non-string handle, a wrong-length tuple, a
|
|
197
|
+
non-dict options -- either raised ``IndexError`` *inside* the GLib
|
|
198
|
+
signal callback, where PyGI prints the traceback to stderr and
|
|
199
|
+
swallows it so the wait hung invisibly (unboundedly, with
|
|
200
|
+
``timeout=None``), or slipped through to the caller and crashed it with
|
|
201
|
+
``ValueError``/``AttributeError``. A *value* inside a correctly-shaped
|
|
202
|
+
options being wrong -- an ``activation_id`` that is not an integer, a
|
|
203
|
+
``cursor_position`` that is not two numbers -- crashed the caller later
|
|
204
|
+
still, doing arithmetic on it. Both now end the wait with this error.
|
|
205
|
+
|
|
206
|
+
``detail`` distinguishes the two: ``None`` for a shape fault (the
|
|
207
|
+
default message names the expected shape), a phrase for a value fault
|
|
208
|
+
(naming the field). ``payload`` is whatever best shows the fault -- the
|
|
209
|
+
whole signal tuple for a shape fault, the options dict for a value one.
|
|
210
|
+
|
|
211
|
+
:func:`_request` raises it the same way for a ``Request``'s
|
|
212
|
+
``Response`` reply that does not match its own ``(u response, a{sv}
|
|
213
|
+
results)`` shape, and :meth:`InputCaptureSession.zones` for a result
|
|
214
|
+
value that is not the type the spec gives it -- always with a
|
|
215
|
+
``detail`` phrase, since the default shape message above is specific
|
|
216
|
+
to the session signals. ``step`` is then the method name, matching the
|
|
217
|
+
sibling errors raised from the same call.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, step: str, payload: object, detail: str | None = None) -> None:
|
|
221
|
+
"""Name the signal, what arrived, and -- for a value fault -- which."""
|
|
222
|
+
reason = detail or (
|
|
223
|
+
"the portal spec gives it the shape (session_handle, options dict)"
|
|
224
|
+
)
|
|
225
|
+
super().__init__(
|
|
226
|
+
f"{step} arrived with a malformed payload: {reason}, got {payload!r}"
|
|
227
|
+
)
|
|
228
|
+
# `step` mirrors the attribute every sibling portal error raised
|
|
229
|
+
# from the same `_wait_for_signal` call site carries
|
|
230
|
+
# (PortalTimeoutError, PortalSessionClosedError), so a caller that
|
|
231
|
+
# catches PortalError and reads `.step` works across all of them.
|
|
232
|
+
self.step = step
|
|
233
|
+
self.payload = payload
|
|
234
|
+
self.detail = detail
|
|
235
|
+
|
|
236
|
+
|
|
188
237
|
class PortalSessionClosedError(PortalError):
|
|
189
238
|
"""The portal session ended while something was still waiting on it.
|
|
190
239
|
|
|
@@ -416,6 +465,18 @@ def _request(
|
|
|
416
465
|
for 60 seconds means the answer arrives inside 60 seconds, not inside
|
|
417
466
|
however many 60-second waits the sequence happens to be built from.
|
|
418
467
|
|
|
468
|
+
A ``Response`` whose payload does not match the spec's ``(u response,
|
|
469
|
+
a{sv} results)`` shape raises :class:`PortalProtocolError` rather than
|
|
470
|
+
being unpacked on faith -- the same hardening `_wait_for_signal`
|
|
471
|
+
applies to the signals it waits on, and for the same two reasons: a
|
|
472
|
+
wrong-arity payload used to raise inside the GLib callback, where PyGI
|
|
473
|
+
swallows it, degrading the step to a full-timeout
|
|
474
|
+
:class:`PortalTimeoutError`; a non-dict ``results`` instead slipped
|
|
475
|
+
through to the caller, whose ``.get()`` crashed with a raw
|
|
476
|
+
``AttributeError`` past ``except PortalError``. The guarantee callers
|
|
477
|
+
rely on is therefore structural: what this returns is always an
|
|
478
|
+
``int`` code and a ``dict`` of results.
|
|
479
|
+
|
|
419
480
|
``trailing_args``, appended after ``options`` in the call's parameter
|
|
420
481
|
tuple, exists for ``InputCapture.SetPointerBarriers`` -- the one
|
|
421
482
|
Request-returning method in either portal whose ``options`` is not its
|
|
@@ -433,12 +494,28 @@ def _request(
|
|
|
433
494
|
|
|
434
495
|
loop = GLib.MainLoop()
|
|
435
496
|
result: dict[str, Any] = {}
|
|
497
|
+
malformed: dict[str, Any] = {}
|
|
436
498
|
subscriptions: list[Any] = []
|
|
437
499
|
# Set inside the timeout callback rather than inferred from an empty
|
|
438
500
|
# `result` afterwards: a Response carrying no results is legitimate
|
|
439
501
|
# (SelectDevices answers with an empty dict), so "did the loop end
|
|
440
502
|
# because it timed out" has to be recorded, not deduced.
|
|
441
503
|
timed_out = False
|
|
504
|
+
# Distinct from `timed_out`: whether `on_timeout` ran at all, either
|
|
505
|
+
# path -- the flag the cleanup below keys "already removed?" on. See
|
|
506
|
+
# the matching pair in `_wait_for_signal`.
|
|
507
|
+
timeout_fired = False
|
|
508
|
+
|
|
509
|
+
def _settled() -> bool:
|
|
510
|
+
"""Whether some terminal outcome has already been recorded.
|
|
511
|
+
|
|
512
|
+
Same role as in `_wait_for_signal`: GLib dispatches every source
|
|
513
|
+
ready in one main-context iteration regardless of an earlier
|
|
514
|
+
``loop.quit()``, so a Response and the deadline can both fire in
|
|
515
|
+
the same poll -- the first recorded outcome wins, and a delivered
|
|
516
|
+
reply is never dropped for a timeout (or vice versa).
|
|
517
|
+
"""
|
|
518
|
+
return bool(result) or bool(malformed) or timed_out
|
|
442
519
|
|
|
443
520
|
def on_response(
|
|
444
521
|
_conn: Any,
|
|
@@ -450,14 +527,42 @@ def _request(
|
|
|
450
527
|
*_a: Any,
|
|
451
528
|
) -> None:
|
|
452
529
|
"""Record the first reply and quit the loop; later replies are ignored."""
|
|
453
|
-
if
|
|
530
|
+
if _settled(): # both subscriptions may fire; the first reply wins
|
|
454
531
|
return
|
|
455
|
-
|
|
532
|
+
args = params.unpack()
|
|
533
|
+
# Checked here, raised only after the loop: this runs inside a
|
|
534
|
+
# GLib callback, where PyGI prints an exception to stderr and
|
|
535
|
+
# swallows it -- a bare raise on a malformed Response would leave
|
|
536
|
+
# the loop running until the full timeout, misreported as
|
|
537
|
+
# PortalTimeoutError; a non-dict `results` let through instead
|
|
538
|
+
# crashed the caller's `.get()` with a raw AttributeError past
|
|
539
|
+
# `except PortalError`. Same hardening `_wait_for_signal` applies
|
|
540
|
+
# to the signals it waits on.
|
|
541
|
+
if (
|
|
542
|
+
not isinstance(args, tuple)
|
|
543
|
+
or len(args) != 2
|
|
544
|
+
or isinstance(args[0], bool)
|
|
545
|
+
or not isinstance(args[0], int)
|
|
546
|
+
or not isinstance(args[1], dict)
|
|
547
|
+
):
|
|
548
|
+
malformed["args"] = args
|
|
549
|
+
loop.quit()
|
|
550
|
+
return
|
|
551
|
+
result["code"], result["results"] = args
|
|
456
552
|
loop.quit()
|
|
457
553
|
|
|
458
554
|
def on_timeout() -> bool:
|
|
459
555
|
"""Stop the loop and record that it timed out rather than finished."""
|
|
460
|
-
nonlocal timed_out
|
|
556
|
+
nonlocal timed_out, timeout_fired
|
|
557
|
+
# Set first, before the early return: GLib removes the source the
|
|
558
|
+
# moment this returns False (which both paths do), so the finally
|
|
559
|
+
# block keys "already removed" on this, not on `timed_out` -- see
|
|
560
|
+
# the same dance in `_wait_for_signal`.
|
|
561
|
+
timeout_fired = True
|
|
562
|
+
if _settled():
|
|
563
|
+
# A Response delivered in the same poll as the deadline has
|
|
564
|
+
# already recorded its outcome; do not overwrite it.
|
|
565
|
+
return False
|
|
461
566
|
timed_out = True
|
|
462
567
|
loop.quit()
|
|
463
568
|
return False # one-shot; GLib removes the source when this is False
|
|
@@ -501,12 +606,14 @@ def _request(
|
|
|
501
606
|
handle = _returned_handle(reply)
|
|
502
607
|
if handle is not None and handle != expected_path:
|
|
503
608
|
subscribe(handle)
|
|
504
|
-
# `if not
|
|
609
|
+
# `if not _settled()` because a synchronous answer (a fast
|
|
505
610
|
# non-interactive Response, or a test double) can arrive during the
|
|
506
611
|
# call above, before run() is reached -- and quit() on a loop that
|
|
507
612
|
# is not running yet does not stop the later run(), so running it
|
|
508
|
-
# then would block with the reply already delivered.
|
|
509
|
-
|
|
613
|
+
# then would block with the reply already delivered. A malformed
|
|
614
|
+
# synchronous reply counts: it too has already quit a loop that
|
|
615
|
+
# was not running yet.
|
|
616
|
+
if not _settled():
|
|
510
617
|
timeout_source = GLib.timeout_add(_msec_until(deadline), on_timeout)
|
|
511
618
|
try:
|
|
512
619
|
loop.run()
|
|
@@ -518,12 +625,27 @@ def _request(
|
|
|
518
625
|
# fixed for _wait_for_signal below. Only remove it when the
|
|
519
626
|
# *Response* woke the loop instead; leaving a live source in
|
|
520
627
|
# that case holds a reference to this closure and fires it
|
|
521
|
-
# into a dead loop on some later request.
|
|
522
|
-
|
|
628
|
+
# into a dead loop on some later request. Keyed on
|
|
629
|
+
# `timeout_fired` (did on_timeout run), not `timed_out` (did
|
|
630
|
+
# the wait time out): a Response landing in the same poll as
|
|
631
|
+
# the deadline runs on_timeout down its `_settled` early
|
|
632
|
+
# return, which removes the source yet leaves `timed_out`
|
|
633
|
+
# False.
|
|
634
|
+
if not timeout_fired:
|
|
523
635
|
GLib.source_remove(timeout_source)
|
|
524
636
|
finally:
|
|
525
637
|
for subscription in subscriptions:
|
|
526
638
|
connection.signal_unsubscribe(subscription)
|
|
639
|
+
# Mirrors `_signal_outcome`: only one of these is ever set (every
|
|
640
|
+
# callback checks `_settled()` first), so the order here only decides
|
|
641
|
+
# between genuinely simultaneous dispatches.
|
|
642
|
+
if malformed:
|
|
643
|
+
raise PortalProtocolError(
|
|
644
|
+
method,
|
|
645
|
+
malformed["args"],
|
|
646
|
+
"the Request's Response signal is specified as "
|
|
647
|
+
"(uint response, results dict)",
|
|
648
|
+
)
|
|
527
649
|
if timed_out:
|
|
528
650
|
raise PortalTimeoutError(method, timeout)
|
|
529
651
|
return result["code"], result["results"]
|
|
@@ -1021,6 +1143,114 @@ def _create_input_capture_session_v1(
|
|
|
1021
1143
|
return session_handle
|
|
1022
1144
|
|
|
1023
1145
|
|
|
1146
|
+
def _classify_signal_payload(
|
|
1147
|
+
args: tuple[Any, ...], session_handle: str, signal: str
|
|
1148
|
+
) -> str:
|
|
1149
|
+
"""How `_wait_for_signal` should treat one unpacked signal payload.
|
|
1150
|
+
|
|
1151
|
+
``"take"`` -- it is this session's, correctly shaped, record it.
|
|
1152
|
+
``"drop"`` -- another session's; keep waiting (logged, since a dropped
|
|
1153
|
+
signal is otherwise indistinguishable from one that never fired).
|
|
1154
|
+
``"malformed"`` -- not the ``(o session_handle, a{sv} options)`` shape
|
|
1155
|
+
the spec gives every signal here.
|
|
1156
|
+
|
|
1157
|
+
Two shape checks with the session filter between them, so how a
|
|
1158
|
+
malformed payload is treated depends on how it is broken. A payload
|
|
1159
|
+
whose *options* are the wrong shape (the second check) is reached only
|
|
1160
|
+
after the filter has matched its handle, so another session's is already
|
|
1161
|
+
dropped before that check runs -- it is raised only for this session. A
|
|
1162
|
+
payload with no first element, or one that is not a string, cannot be
|
|
1163
|
+
matched to any session at all: there is no handle to compare it with, so
|
|
1164
|
+
it is malformed for every possible waiter and is raised here whichever
|
|
1165
|
+
session it might have been meant for -- the filter cannot run before it,
|
|
1166
|
+
only after. In the single-session process this almost always is, the
|
|
1167
|
+
distinction never arises.
|
|
1168
|
+
"""
|
|
1169
|
+
if not args or not isinstance(args[0], str):
|
|
1170
|
+
return "malformed"
|
|
1171
|
+
if args[0] != session_handle:
|
|
1172
|
+
logger.debug(
|
|
1173
|
+
"ignoring %s for session %s while waiting for %s",
|
|
1174
|
+
signal,
|
|
1175
|
+
args[0],
|
|
1176
|
+
session_handle,
|
|
1177
|
+
)
|
|
1178
|
+
return "drop"
|
|
1179
|
+
if len(args) != 2 or not isinstance(args[1], dict):
|
|
1180
|
+
return "malformed"
|
|
1181
|
+
return "take"
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _spec_int(value: Any) -> int:
|
|
1185
|
+
"""``int(value)`` for a spec ``u``/``i``, refusing a silent truncation.
|
|
1186
|
+
|
|
1187
|
+
A portal integer field arrives as a Python ``int``. A malformed portal
|
|
1188
|
+
that put a ``d`` (double) there would still reach ``int()``, and
|
|
1189
|
+
``int(0.9) == 0`` / ``int(1.9) == 1`` truncates it *silently* -- a zone
|
|
1190
|
+
coordinate or a refused barrier id quietly becomes a different,
|
|
1191
|
+
valid-looking value rather than an error. Reject any ``float``
|
|
1192
|
+
(``inf``/``nan`` included) with the ``ValueError`` the surrounding
|
|
1193
|
+
guards already turn into a :class:`PortalProtocolError`, so an off-spec
|
|
1194
|
+
fractional value is refused on the same footing as a non-numeric one.
|
|
1195
|
+
"""
|
|
1196
|
+
if isinstance(value, float):
|
|
1197
|
+
raise ValueError("a fractional value where the spec gives an integer")
|
|
1198
|
+
return int(value)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _deactivation_id(options: dict[str, Any], signal: str) -> int:
|
|
1202
|
+
"""The ``activation_id`` from Deactivated's options, as the spec's ``u``.
|
|
1203
|
+
|
|
1204
|
+
``_wait_for_signal`` checks the payload's *shape* -- that options is a
|
|
1205
|
+
dict -- but not the types of the values in it. ``activation_id`` is a
|
|
1206
|
+
``u``, and a non-conforming portal that put something else there would
|
|
1207
|
+
crash the caller with ``ValueError``/``TypeError``/``OverflowError``
|
|
1208
|
+
past the shape check. Converting it here turns that into the same typed
|
|
1209
|
+
:class:`PortalProtocolError` the shape check raises, so a caller that
|
|
1210
|
+
guards against malformed signals catches one error, not two.
|
|
1211
|
+
"""
|
|
1212
|
+
try:
|
|
1213
|
+
return _spec_int(options.get("activation_id", 0))
|
|
1214
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
1215
|
+
raise PortalProtocolError(
|
|
1216
|
+
signal, options, "activation_id is not an integer"
|
|
1217
|
+
) from exc
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _activation_from_options(options: dict[str, Any], signal: str) -> Activation:
|
|
1221
|
+
"""Build an :class:`Activation` from Activated's options, spec-typed.
|
|
1222
|
+
|
|
1223
|
+
The companion to the shape check in ``_wait_for_signal`` for the value
|
|
1224
|
+
level: every field the portal spec types is converted here under one
|
|
1225
|
+
guard, so a non-conforming value in any of them -- an ``activation_id``
|
|
1226
|
+
that is not a ``u``, a ``cursor_position`` that is not two ``d``s, a
|
|
1227
|
+
``barrier_id`` that is not a ``u`` -- becomes the same typed
|
|
1228
|
+
:class:`PortalProtocolError` rather than a crash in the caller later,
|
|
1229
|
+
doing arithmetic on a string. One guard rather than three means the
|
|
1230
|
+
next field added to the signal is validated by construction, not by a
|
|
1231
|
+
fourth hand-rolled check nobody remembers to write.
|
|
1232
|
+
"""
|
|
1233
|
+
try:
|
|
1234
|
+
activation_id = _spec_int(options.get("activation_id", 0))
|
|
1235
|
+
cursor = options.get("cursor_position")
|
|
1236
|
+
if cursor is None:
|
|
1237
|
+
cursor_position = None
|
|
1238
|
+
else:
|
|
1239
|
+
cx, cy = cursor # (dd): exactly two, or this unpacking raises
|
|
1240
|
+
cursor_position = (float(cx), float(cy))
|
|
1241
|
+
barrier = options.get("barrier_id")
|
|
1242
|
+
barrier_id = None if barrier is None else _spec_int(barrier)
|
|
1243
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
1244
|
+
raise PortalProtocolError(
|
|
1245
|
+
signal, options, "an option value is not the type the spec gives it"
|
|
1246
|
+
) from exc
|
|
1247
|
+
return Activation(
|
|
1248
|
+
activation_id=activation_id,
|
|
1249
|
+
cursor_position=cursor_position,
|
|
1250
|
+
barrier_id=barrier_id,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
|
|
1024
1254
|
def _wait_for_signal(
|
|
1025
1255
|
connection: Any,
|
|
1026
1256
|
Gio: Any,
|
|
@@ -1056,12 +1286,41 @@ def _wait_for_signal(
|
|
|
1056
1286
|
D-Bus specification restricts plain ``arg0=`` match rules to arguments
|
|
1057
1287
|
of type STRING, and this one is an OBJECT_PATH, which is the same shape
|
|
1058
1288
|
of silent non-delivery all over again.
|
|
1289
|
+
|
|
1290
|
+
A payload that does not match the spec's ``(o session_handle, a{sv}
|
|
1291
|
+
options)`` shape raises :class:`PortalProtocolError` rather than being
|
|
1292
|
+
unpacked on faith -- see that class for why the alternative was not an
|
|
1293
|
+
exception but a hang.
|
|
1059
1294
|
"""
|
|
1060
1295
|
loop = GLib.MainLoop()
|
|
1061
1296
|
result: dict[str, Any] = {}
|
|
1297
|
+
malformed: dict[str, Any] = {}
|
|
1062
1298
|
timed_out = False
|
|
1299
|
+
# Distinct from `timed_out`: whether `on_timeout` ran at all, either
|
|
1300
|
+
# path. `on_timeout` returns False whichever branch it takes, and False
|
|
1301
|
+
# is what makes GLib deregister the source -- so the finally block must
|
|
1302
|
+
# key its "already removed?" check on "did on_timeout fire", not on "did
|
|
1303
|
+
# the wait time out". When a signal and the deadline land in the same
|
|
1304
|
+
# poll, on_timeout takes the `_settled()` early return (leaving
|
|
1305
|
+
# `timed_out` False) yet its False return still removed the source;
|
|
1306
|
+
# keying on `timed_out` there would double-remove it and trip the very
|
|
1307
|
+
# "Source ID N was not found" critical the guard exists to avoid.
|
|
1308
|
+
timeout_fired = False
|
|
1063
1309
|
session_closed = False
|
|
1064
1310
|
|
|
1311
|
+
def _settled() -> bool:
|
|
1312
|
+
"""Whether some terminal outcome has already been recorded.
|
|
1313
|
+
|
|
1314
|
+
GLib dispatches every source ready in one main-context iteration,
|
|
1315
|
+
regardless of an earlier ``loop.quit()``, so two outcomes -- a
|
|
1316
|
+
signal and the timeout, a signal and Closed -- can both fire in the
|
|
1317
|
+
same poll. Every callback checks this first so the *first* recorded
|
|
1318
|
+
outcome wins and the post-loop raise order is never handed a second
|
|
1319
|
+
one to misreport (a delivered activation dropped for a timeout, a
|
|
1320
|
+
protocol fault overwritten by session-closed).
|
|
1321
|
+
"""
|
|
1322
|
+
return bool(result) or bool(malformed) or timed_out or session_closed
|
|
1323
|
+
|
|
1065
1324
|
def on_closed(*_args: Any) -> None:
|
|
1066
1325
|
"""End the wait: a closed session can never deliver the signal.
|
|
1067
1326
|
|
|
@@ -1076,7 +1335,7 @@ def _wait_for_signal(
|
|
|
1076
1335
|
that merely had not fired yet.
|
|
1077
1336
|
"""
|
|
1078
1337
|
nonlocal session_closed
|
|
1079
|
-
if
|
|
1338
|
+
if _settled():
|
|
1080
1339
|
return
|
|
1081
1340
|
logger.debug("session %s closed while waiting for %s", session_handle, signal)
|
|
1082
1341
|
session_closed = True
|
|
@@ -1092,21 +1351,22 @@ def _wait_for_signal(
|
|
|
1092
1351
|
*_a: Any,
|
|
1093
1352
|
) -> None:
|
|
1094
1353
|
"""Record the first matching signal; anything else is logged and dropped."""
|
|
1095
|
-
if
|
|
1354
|
+
if _settled(): # a subscription outliving its wait fires twice
|
|
1096
1355
|
return
|
|
1097
1356
|
args = params.unpack()
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1357
|
+
# The verdict is computed by `_classify_signal_payload`, but ACTED
|
|
1358
|
+
# on here and the error raised only after the loop, because this
|
|
1359
|
+
# runs inside a GLib callback: PyGI prints an exception raised here
|
|
1360
|
+
# to stderr and swallows it, so a bare raise on a malformed payload
|
|
1361
|
+
# would leave the loop running -- a timeout at best, an unbounded
|
|
1362
|
+
# hang with timeout=None, both indistinguishable from a compositor
|
|
1363
|
+
# that never fired.
|
|
1364
|
+
verdict = _classify_signal_payload(args, session_handle, signal)
|
|
1365
|
+
if verdict == "drop":
|
|
1366
|
+
return
|
|
1367
|
+
if verdict == "malformed":
|
|
1368
|
+
malformed["args"] = args
|
|
1369
|
+
loop.quit()
|
|
1110
1370
|
return
|
|
1111
1371
|
logger.debug("received %s for session %s", signal, session_handle)
|
|
1112
1372
|
result["args"] = args
|
|
@@ -1114,7 +1374,16 @@ def _wait_for_signal(
|
|
|
1114
1374
|
|
|
1115
1375
|
def on_timeout() -> bool:
|
|
1116
1376
|
"""Stop the loop and record that it timed out rather than finished."""
|
|
1117
|
-
nonlocal timed_out
|
|
1377
|
+
nonlocal timed_out, timeout_fired
|
|
1378
|
+
# Set first, before the early return: GLib removes the source the
|
|
1379
|
+
# moment this returns False (which both paths do), so the finally
|
|
1380
|
+
# block keys "already removed" on this, not on `timed_out`.
|
|
1381
|
+
timeout_fired = True
|
|
1382
|
+
if _settled():
|
|
1383
|
+
# A signal delivered in the same poll as the deadline has
|
|
1384
|
+
# already recorded its outcome; the timeout must not overwrite
|
|
1385
|
+
# a real activation with PortalTimeoutError.
|
|
1386
|
+
return False
|
|
1118
1387
|
timed_out = True
|
|
1119
1388
|
loop.quit()
|
|
1120
1389
|
return False
|
|
@@ -1148,7 +1417,7 @@ def _wait_for_signal(
|
|
|
1148
1417
|
session_handle,
|
|
1149
1418
|
)
|
|
1150
1419
|
try:
|
|
1151
|
-
if not result and not session_closed:
|
|
1420
|
+
if not result and not session_closed and not malformed:
|
|
1152
1421
|
timeout_source = None
|
|
1153
1422
|
if timeout is not None:
|
|
1154
1423
|
timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
|
|
@@ -1164,13 +1433,41 @@ def _wait_for_signal(
|
|
|
1164
1433
|
# every real timeout this session hit, never against
|
|
1165
1434
|
# test_portal.py's fake GLib.source_remove, which just
|
|
1166
1435
|
# clears pending_timeout with no complaint either way.
|
|
1167
|
-
|
|
1436
|
+
# Keyed on `timeout_fired` (did on_timeout run), not
|
|
1437
|
+
# `timed_out` (did the wait time out): a signal landing in
|
|
1438
|
+
# the same poll as the deadline runs on_timeout down its
|
|
1439
|
+
# `_settled` early return, which removes the source yet
|
|
1440
|
+
# leaves `timed_out` False.
|
|
1441
|
+
if timeout_source is not None and not timeout_fired:
|
|
1168
1442
|
GLib.source_remove(timeout_source)
|
|
1169
1443
|
finally:
|
|
1170
1444
|
connection.signal_unsubscribe(subscription)
|
|
1171
1445
|
connection.signal_unsubscribe(closed_subscription)
|
|
1446
|
+
return _signal_outcome(
|
|
1447
|
+
signal, timeout, result, malformed, timed_out, session_closed
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _signal_outcome(
|
|
1452
|
+
signal: str,
|
|
1453
|
+
timeout: float | None,
|
|
1454
|
+
result: dict[str, Any],
|
|
1455
|
+
malformed: dict[str, Any],
|
|
1456
|
+
timed_out: bool,
|
|
1457
|
+
session_closed: bool,
|
|
1458
|
+
) -> tuple[Any, ...]:
|
|
1459
|
+
"""Raise for the terminal outcome `_wait_for_signal` recorded, or return it.
|
|
1460
|
+
|
|
1461
|
+
Order is load-bearing and matches the callbacks' `_settled` guard:
|
|
1462
|
+
whichever outcome was recorded first is the only one set, so this order
|
|
1463
|
+
only ever decides between genuinely simultaneous dispatches, where a
|
|
1464
|
+
session that has closed or a payload that was malformed is the more
|
|
1465
|
+
useful answer than "timed out".
|
|
1466
|
+
"""
|
|
1172
1467
|
if session_closed:
|
|
1173
1468
|
raise PortalSessionClosedError(signal)
|
|
1469
|
+
if malformed:
|
|
1470
|
+
raise PortalProtocolError(signal, malformed["args"])
|
|
1174
1471
|
if timed_out:
|
|
1175
1472
|
# timed_out is only ever set inside on_timeout, itself only ever
|
|
1176
1473
|
# registered when timeout is not None -- so this always holds, but
|
|
@@ -1408,9 +1705,26 @@ class InputCaptureSession:
|
|
|
1408
1705
|
)
|
|
1409
1706
|
if code != 0:
|
|
1410
1707
|
raise PortalDeniedError("GetZones")
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1708
|
+
# `results` is a dict by `_request`'s own guarantee; the *values*
|
|
1709
|
+
# are still the portal's to malform. The spec types them --
|
|
1710
|
+
# `zone_set` is a `u`, `zones` an `a(uuii)` -- so convert them
|
|
1711
|
+
# under one guard, the same way `_activation_from_options` covers
|
|
1712
|
+
# the Activated signal's values: a non-conforming value becomes
|
|
1713
|
+
# the typed PortalProtocolError rather than a raw
|
|
1714
|
+
# TypeError/ValueError past `except PortalError` in the caller.
|
|
1715
|
+
try:
|
|
1716
|
+
zone_set = _spec_int(results.get("zone_set", 0))
|
|
1717
|
+
zones = [
|
|
1718
|
+
(_spec_int(w), _spec_int(h), _spec_int(x), _spec_int(y))
|
|
1719
|
+
for w, h, x, y in results.get("zones", [])
|
|
1720
|
+
]
|
|
1721
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
1722
|
+
raise PortalProtocolError(
|
|
1723
|
+
"GetZones",
|
|
1724
|
+
results,
|
|
1725
|
+
"a result value is not the type the spec gives it",
|
|
1726
|
+
) from exc
|
|
1727
|
+
return zone_set, zones
|
|
1414
1728
|
|
|
1415
1729
|
def set_pointer_barriers(
|
|
1416
1730
|
self,
|
|
@@ -1459,7 +1773,19 @@ class InputCaptureSession:
|
|
|
1459
1773
|
)
|
|
1460
1774
|
if code != 0:
|
|
1461
1775
|
raise PortalDeniedError("SetPointerBarriers")
|
|
1462
|
-
|
|
1776
|
+
# `results` is a dict by `_request`'s own guarantee; the *value* at
|
|
1777
|
+
# `failed_barriers` (an `au` per the spec) is not. A non-iterable
|
|
1778
|
+
# there would crash the caller with a raw TypeError past
|
|
1779
|
+
# `except PortalError` -- the same value-level hole `zones()` guards
|
|
1780
|
+
# -- so a malformed one becomes the typed PortalProtocolError.
|
|
1781
|
+
try:
|
|
1782
|
+
failed = [_spec_int(b) for b in results.get("failed_barriers", [])]
|
|
1783
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
1784
|
+
raise PortalProtocolError(
|
|
1785
|
+
"SetPointerBarriers",
|
|
1786
|
+
results,
|
|
1787
|
+
"a result value is not the type the spec gives it",
|
|
1788
|
+
) from exc
|
|
1463
1789
|
# A partially-refused set is the quiet failure here: the call
|
|
1464
1790
|
# succeeds, some barriers stand, and an edge the caller believes is
|
|
1465
1791
|
# armed simply never triggers. Callers see only the returned list,
|
|
@@ -1497,12 +1823,7 @@ class InputCaptureSession:
|
|
|
1497
1823
|
self.session_handle,
|
|
1498
1824
|
timeout,
|
|
1499
1825
|
)
|
|
1500
|
-
|
|
1501
|
-
return Activation(
|
|
1502
|
-
activation_id=int(options.get("activation_id", 0)),
|
|
1503
|
-
cursor_position=tuple(cursor) if cursor is not None else None,
|
|
1504
|
-
barrier_id=options.get("barrier_id"),
|
|
1505
|
-
)
|
|
1826
|
+
return _activation_from_options(options, "Activated")
|
|
1506
1827
|
|
|
1507
1828
|
def wait_for_deactivation(self, timeout: float | None = None) -> int:
|
|
1508
1829
|
"""Block until the current capture ends, returning its activation_id."""
|
|
@@ -1520,7 +1841,7 @@ class InputCaptureSession:
|
|
|
1520
1841
|
self.session_handle,
|
|
1521
1842
|
timeout,
|
|
1522
1843
|
)
|
|
1523
|
-
return
|
|
1844
|
+
return _deactivation_id(options, "Deactivated")
|
|
1524
1845
|
|
|
1525
1846
|
def close(self) -> None:
|
|
1526
1847
|
"""End the portal session, and close the EIS fd if unclaimed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
|
|
5
5
|
Author: Dennis K. Paulsen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -175,7 +175,7 @@ and why, is in
|
|
|
175
175
|
|
|
176
176
|
## Status
|
|
177
177
|
|
|
178
|
-
Beta (`0.
|
|
178
|
+
Beta (`0.8.1`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
179
179
|
since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
|
|
180
180
|
|
|
181
181
|
The injection path is exercised end to end against the real libraries by the
|
|
@@ -228,17 +228,27 @@ Exactly what was run, when, and against which versions:
|
|
|
228
228
|
|
|
229
229
|
| Needs | For |
|
|
230
230
|
| --- | --- |
|
|
231
|
-
| 1.1 | `Region.mapping_id`, `
|
|
231
|
+
| 1.1 | `Region.mapping_id`, `Device.region_at()` |
|
|
232
232
|
| 1.4 | ping/pong round trips (`Context.new_ping()`), `Context.disconnect()`, touch cancellation (`Touch.cancel()`) |
|
|
233
233
|
| 1.5 | `eis.Client.pid` |
|
|
234
234
|
| 1.6 | `Device.text_utf8()` / `text_keysym()` and TEXT events, `Seat.request_device()`, `Eis.set_flag()` |
|
|
235
235
|
|
|
236
236
|
Nothing is resolved until it is called, so a build without one of these
|
|
237
237
|
costs you only that call — it raises `LibraryNotFoundError` naming the
|
|
238
|
-
missing symbol, while the rest of the package keeps working.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
missing symbol, while the rest of the package keeps working. Two
|
|
239
|
+
accessors degrade instead of raising, because every supported version
|
|
240
|
+
exports the symbol and only the answer changed — so there is nothing to
|
|
241
|
+
resolve and nothing to report:
|
|
242
|
+
|
|
243
|
+
- `Event.touch_up_event` reports `is_cancel=False` on libei older than
|
|
244
|
+
1.4, since a library with no notion of cancellation genuinely never
|
|
245
|
+
sends one.
|
|
246
|
+
- `eis.Event.emulating_sequence` reads 0 on libei older than 1.6
|
|
247
|
+
whatever the client sent: libeis queued the start_emulating event
|
|
248
|
+
without ever copying the sequence into it, until 1.6.0 added the
|
|
249
|
+
missing assignment. A 0 on an older build is indistinguishable from a
|
|
250
|
+
client that really sent 0. The client-side
|
|
251
|
+
`ei.Event.emulating_sequence` is unaffected.
|
|
242
252
|
|
|
243
253
|
These versions were established by building libei 1.2.1 and diffing its
|
|
244
254
|
exported symbols against every binding here, not by reading `@since`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
libei/__init__.py,sha256=7fJVf_RPxcEEeOM2YTQHtJPP3ilPhFREQKNmCoJqOL4,1858
|
|
2
|
+
libei/_cobject.py,sha256=XjbUNJpZ87jAyPWh6dmRslk6cMy831oMRFhWmfjLDFo,13344
|
|
3
|
+
libei/ei.py,sha256=3oaZ4dxo-ytBHtES3_1_LUca2ZbR8r3SwA50Rj0hGxo,49541
|
|
4
|
+
libei/eis.py,sha256=kcFi6vfw9F9uBt6Bz-blGD-0M6mLEMTYDCgC_SKK7vU,46843
|
|
5
|
+
libei/oeffis.py,sha256=gJHR90s9VhQrgkjWUMDx-lxZ8qKxkttnqbx_xo-7oxs,10035
|
|
6
|
+
libei/portal.py,sha256=95f3I5q8BF2ehGP6LEEMzRKTisrwBxkf20rY1MvD1ic,88168
|
|
7
|
+
libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
libei/_capi/__init__.py,sha256=raISSWSV9hAcE92DcLCz-vUb1IPcsmxRZaS-iAoQJBE,211
|
|
9
|
+
libei/_capi/libei.py,sha256=AQgVZ2ZsHH8rywwcmq1EJ-IauuNHbBMhwauMelX83yE,11754
|
|
10
|
+
libei/_capi/libeis.py,sha256=C_Uh7DwNncFC8A4wuMhviAj5C_TUmKHtLJYq0Qhhz1E,13142
|
|
11
|
+
libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
|
|
12
|
+
libei/_capi/loader.py,sha256=xW67uk35ckgeFaheDGrHC05ZM5w_cxBGvX5lG_GUSu8,6625
|
|
13
|
+
python_libei-0.8.1.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
|
|
14
|
+
python_libei-0.8.1.dist-info/METADATA,sha256=gGc0OayDeot7yFnA2wmURo_xZjjr1UoQxpK7mdynrWk,23364
|
|
15
|
+
python_libei-0.8.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
16
|
+
python_libei-0.8.1.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
|
|
17
|
+
python_libei-0.8.1.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
libei/__init__.py,sha256=3Ru2esZsveT5oIlBPJ_6DH8RsRoofVvvf27WZafcOZg,1858
|
|
2
|
-
libei/_cobject.py,sha256=XjbUNJpZ87jAyPWh6dmRslk6cMy831oMRFhWmfjLDFo,13344
|
|
3
|
-
libei/ei.py,sha256=W4akKc_BNVfL0FroSXLGD1Z_L3OmYjZT5QDHNnEtMP8,49367
|
|
4
|
-
libei/eis.py,sha256=X_umAaAAbAMC9d2RdnMReeJZI61WsnKz3cySY5YrGr0,45841
|
|
5
|
-
libei/oeffis.py,sha256=gJHR90s9VhQrgkjWUMDx-lxZ8qKxkttnqbx_xo-7oxs,10035
|
|
6
|
-
libei/portal.py,sha256=Q2GbZBUnKJZ90WJtDtpkcgQbvsCLY6xJwngmYRg5qQ0,72089
|
|
7
|
-
libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
libei/_capi/__init__.py,sha256=raISSWSV9hAcE92DcLCz-vUb1IPcsmxRZaS-iAoQJBE,211
|
|
9
|
-
libei/_capi/libei.py,sha256=qsccHa_komCxtvPYi6AobszhFbEG2qA8QYJJVr8SaGM,11356
|
|
10
|
-
libei/_capi/libeis.py,sha256=C_Uh7DwNncFC8A4wuMhviAj5C_TUmKHtLJYq0Qhhz1E,13142
|
|
11
|
-
libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
|
|
12
|
-
libei/_capi/loader.py,sha256=xW67uk35ckgeFaheDGrHC05ZM5w_cxBGvX5lG_GUSu8,6625
|
|
13
|
-
python_libei-0.7.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
|
|
14
|
-
python_libei-0.7.0.dist-info/METADATA,sha256=kCY_c4mpI43wAk_72MFq1yyv3VZm8r9NBaexaQzgWCg,22882
|
|
15
|
-
python_libei-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
-
python_libei-0.7.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
|
|
17
|
-
python_libei-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|