python-libei 0.4.0__py3-none-any.whl → 0.5.0__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 +4 -2
- libei/portal.py +632 -4
- python_libei-0.5.0.dist-info/METADATA +419 -0
- {python_libei-0.4.0.dist-info → python_libei-0.5.0.dist-info}/RECORD +7 -7
- python_libei-0.4.0.dist-info/METADATA +0 -848
- {python_libei-0.4.0.dist-info → python_libei-0.5.0.dist-info}/WHEEL +0 -0
- {python_libei-0.4.0.dist-info → python_libei-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_libei-0.4.0.dist-info → python_libei-0.5.0.dist-info}/top_level.txt +0 -0
libei/portal.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
"""Negotiate an EIS connection by driving
|
|
2
|
-
|
|
1
|
+
"""Negotiate an EIS connection by driving a portal directly over D-Bus,
|
|
2
|
+
rather than through :mod:`libei.oeffis`.
|
|
3
|
+
|
|
4
|
+
Two portals, two directions. :class:`RemoteDesktopSession` negotiates
|
|
5
|
+
``org.freedesktop.portal.RemoteDesktop`` to *inject* input; below it,
|
|
6
|
+
:class:`InputCaptureSession` negotiates the separate
|
|
7
|
+
``org.freedesktop.portal.InputCapture`` to *receive* real input from the
|
|
8
|
+
user's own devices instead -- see its own class docstring, including why
|
|
9
|
+
it has never been run against a real portal. Everything in this module
|
|
10
|
+
docstring up to :class:`InputCaptureSession`'s own section is about
|
|
11
|
+
``RemoteDesktopSession`` specifically.
|
|
3
12
|
|
|
4
13
|
:mod:`libei.oeffis` wraps liboeffis, whose C API
|
|
5
14
|
(``oeffis_create_session()``) takes only a device-type bitmask -- it exposes
|
|
@@ -65,7 +74,7 @@ import logging
|
|
|
65
74
|
import os
|
|
66
75
|
import time
|
|
67
76
|
import uuid
|
|
68
|
-
from typing import Any
|
|
77
|
+
from typing import Any, NamedTuple
|
|
69
78
|
|
|
70
79
|
from .oeffis import DeviceType
|
|
71
80
|
|
|
@@ -79,16 +88,20 @@ __all__ = [
|
|
|
79
88
|
"PortalDeniedError",
|
|
80
89
|
"PortalTimeoutError",
|
|
81
90
|
"RemoteDesktopSession",
|
|
91
|
+
"Activation",
|
|
92
|
+
"InputCaptureSession",
|
|
82
93
|
"is_available",
|
|
83
94
|
]
|
|
84
95
|
|
|
85
96
|
_BUS_NAME = "org.freedesktop.portal.Desktop"
|
|
86
97
|
_OBJECT_PATH = "/org/freedesktop/portal/desktop"
|
|
87
98
|
_REMOTE_DESKTOP = "org.freedesktop.portal.RemoteDesktop"
|
|
99
|
+
_INPUT_CAPTURE = "org.freedesktop.portal.InputCapture"
|
|
88
100
|
_REQUEST_INTERFACE = "org.freedesktop.portal.Request"
|
|
89
101
|
_SESSION_INTERFACE = "org.freedesktop.portal.Session"
|
|
90
102
|
|
|
91
103
|
_MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
|
|
104
|
+
_MIN_INPUT_CAPTURE_VERSION = 2 # CreateSession2 is a v2-only method
|
|
92
105
|
|
|
93
106
|
_DEFAULT_TIMEOUT = 60.0
|
|
94
107
|
"""Seconds to wait for one portal round trip. Generous, because a human has
|
|
@@ -351,6 +364,7 @@ def _request(
|
|
|
351
364
|
leading_args: tuple[Any, ...],
|
|
352
365
|
options: dict[str, Any],
|
|
353
366
|
timeout: float,
|
|
367
|
+
trailing_args: tuple[Any, ...] = (),
|
|
354
368
|
) -> tuple[int, Any]:
|
|
355
369
|
"""Call a Request-returning portal method, racelessly.
|
|
356
370
|
|
|
@@ -374,6 +388,13 @@ def _request(
|
|
|
374
388
|
Both legs share one deadline (see `_msec_until`), since a caller asking
|
|
375
389
|
for 60 seconds means the answer arrives inside 60 seconds, not inside
|
|
376
390
|
however many 60-second waits the sequence happens to be built from.
|
|
391
|
+
|
|
392
|
+
``trailing_args``, appended after ``options`` in the call's parameter
|
|
393
|
+
tuple, exists for ``InputCapture.SetPointerBarriers`` -- the one
|
|
394
|
+
Request-returning method in either portal whose ``options`` is not its
|
|
395
|
+
last positional argument (``barriers`` and ``zone_set`` follow it).
|
|
396
|
+
Every RemoteDesktop call leaves this at its default, reproducing the
|
|
397
|
+
exact parameter tuple this function always built.
|
|
377
398
|
"""
|
|
378
399
|
deadline = time.monotonic() + timeout
|
|
379
400
|
unique_name = connection.get_unique_name()
|
|
@@ -428,7 +449,7 @@ def _request(
|
|
|
428
449
|
|
|
429
450
|
subscribe(expected_path)
|
|
430
451
|
try:
|
|
431
|
-
parameters = GLib.Variant(signature, (*leading_args, options))
|
|
452
|
+
parameters = GLib.Variant(signature, (*leading_args, options, *trailing_args))
|
|
432
453
|
reply = _call_sync(
|
|
433
454
|
connection,
|
|
434
455
|
Gio,
|
|
@@ -883,3 +904,610 @@ class RemoteDesktopSession:
|
|
|
883
904
|
session_handle,
|
|
884
905
|
busname,
|
|
885
906
|
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _input_capture_version(
|
|
910
|
+
connection: Any, Gio: Any, GLib: Any, busname: str, timeout: float
|
|
911
|
+
) -> int:
|
|
912
|
+
"""Read the InputCapture portal's ``version`` property."""
|
|
913
|
+
reply = _call_sync(
|
|
914
|
+
connection,
|
|
915
|
+
Gio,
|
|
916
|
+
GLib,
|
|
917
|
+
busname,
|
|
918
|
+
_OBJECT_PATH,
|
|
919
|
+
"org.freedesktop.DBus.Properties",
|
|
920
|
+
"Get",
|
|
921
|
+
GLib.Variant("(ss)", (_INPUT_CAPTURE, "version")),
|
|
922
|
+
None,
|
|
923
|
+
int(timeout * 1000),
|
|
924
|
+
)
|
|
925
|
+
(version,) = reply.unpack()
|
|
926
|
+
return int(version)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _wait_for_signal(
|
|
930
|
+
connection: Any,
|
|
931
|
+
Gio: Any,
|
|
932
|
+
GLib: Any,
|
|
933
|
+
busname: str,
|
|
934
|
+
interface: str,
|
|
935
|
+
signal: str,
|
|
936
|
+
path: str,
|
|
937
|
+
timeout: float | None,
|
|
938
|
+
) -> tuple[Any, ...]:
|
|
939
|
+
"""Block for one emission of ``signal`` on ``path``, unpacked.
|
|
940
|
+
|
|
941
|
+
Unlike `_request`, nothing here *triggers* the signal: ``Activated`` and
|
|
942
|
+
``Deactivated`` fire whenever the compositor decides a pointer barrier
|
|
943
|
+
was crossed, which the caller has no control over and which may happen
|
|
944
|
+
before this is even called (a long-enabled session, subscribed to
|
|
945
|
+
late) or not for a long time. ``timeout=None`` waits indefinitely --
|
|
946
|
+
the read a caller wants when there is nothing else useful to do but
|
|
947
|
+
wait for a human to move the pointer.
|
|
948
|
+
"""
|
|
949
|
+
loop = GLib.MainLoop()
|
|
950
|
+
result: dict[str, Any] = {}
|
|
951
|
+
timed_out = False
|
|
952
|
+
|
|
953
|
+
def on_signal(
|
|
954
|
+
_conn: Any,
|
|
955
|
+
_sender: Any,
|
|
956
|
+
_path: Any,
|
|
957
|
+
_iface: Any,
|
|
958
|
+
_signal: Any,
|
|
959
|
+
params: Any,
|
|
960
|
+
*_a: Any,
|
|
961
|
+
) -> None:
|
|
962
|
+
if result: # a subscription that outlives its own wait can fire twice
|
|
963
|
+
return
|
|
964
|
+
result["args"] = params.unpack()
|
|
965
|
+
loop.quit()
|
|
966
|
+
|
|
967
|
+
def on_timeout() -> bool:
|
|
968
|
+
nonlocal timed_out
|
|
969
|
+
timed_out = True
|
|
970
|
+
loop.quit()
|
|
971
|
+
return False
|
|
972
|
+
|
|
973
|
+
subscription = connection.signal_subscribe(
|
|
974
|
+
busname,
|
|
975
|
+
interface,
|
|
976
|
+
signal,
|
|
977
|
+
path,
|
|
978
|
+
None,
|
|
979
|
+
Gio.DBusSignalFlags.NONE,
|
|
980
|
+
on_signal,
|
|
981
|
+
None,
|
|
982
|
+
)
|
|
983
|
+
try:
|
|
984
|
+
if not result:
|
|
985
|
+
timeout_source = None
|
|
986
|
+
if timeout is not None:
|
|
987
|
+
timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
|
|
988
|
+
try:
|
|
989
|
+
loop.run()
|
|
990
|
+
finally:
|
|
991
|
+
if timeout_source is not None:
|
|
992
|
+
GLib.source_remove(timeout_source)
|
|
993
|
+
finally:
|
|
994
|
+
connection.signal_unsubscribe(subscription)
|
|
995
|
+
if timed_out:
|
|
996
|
+
# timed_out is only ever set inside on_timeout, itself only ever
|
|
997
|
+
# registered when timeout is not None -- so this always holds, but
|
|
998
|
+
# not in a shape mypy can see across the closure.
|
|
999
|
+
assert timeout is not None
|
|
1000
|
+
raise PortalTimeoutError(signal, timeout)
|
|
1001
|
+
return result["args"]
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class Activation(NamedTuple):
|
|
1005
|
+
"""One ``Activated`` signal's payload -- see
|
|
1006
|
+
:meth:`InputCaptureSession.wait_for_activation`.
|
|
1007
|
+
"""
|
|
1008
|
+
|
|
1009
|
+
activation_id: int
|
|
1010
|
+
"""Pass this back to :meth:`InputCaptureSession.release`. Wraps around;
|
|
1011
|
+
do not assume it only increases within one process's lifetime."""
|
|
1012
|
+
|
|
1013
|
+
cursor_position: tuple[float, float] | None
|
|
1014
|
+
"""Where the pointer was, in the coordinate space :meth:`
|
|
1015
|
+
InputCaptureSession.zones` reports -- usually *outside* every zone,
|
|
1016
|
+
since a barrier sits on a zone's own edge. None if the compositor sent
|
|
1017
|
+
none, which the spec permits."""
|
|
1018
|
+
|
|
1019
|
+
barrier_id: int | None
|
|
1020
|
+
"""The barrier that triggered, matching one passed to
|
|
1021
|
+
:meth:`InputCaptureSession.set_pointer_barriers` -- 0 if the compositor
|
|
1022
|
+
could not determine which, None if capture was not triggered by a
|
|
1023
|
+
barrier at all."""
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
class InputCaptureSession:
|
|
1027
|
+
"""A negotiated ``org.freedesktop.portal.InputCapture`` session.
|
|
1028
|
+
|
|
1029
|
+
The read half of what :class:`RemoteDesktopSession` is for the write
|
|
1030
|
+
direction: instead of injecting synthetic input, this receives real
|
|
1031
|
+
input from the user's own devices once the compositor decides to divert
|
|
1032
|
+
it here. That decision is the whole point of the protocol and is never
|
|
1033
|
+
this session's to make -- see :meth:`enable` and :meth:`
|
|
1034
|
+
wait_for_activation`.
|
|
1035
|
+
|
|
1036
|
+
**Capturing is exclusive.** Once the compositor activates a capture,
|
|
1037
|
+
the events it captures stop reaching the desktop entirely and are sent
|
|
1038
|
+
only to this session over the EIS connection -- there is no
|
|
1039
|
+
"observe without diverting" mode. A caller holding this open across
|
|
1040
|
+
more than the moment it needs is holding the user's pointer or keyboard
|
|
1041
|
+
hostage from their own desktop; keep the enabled window as short as
|
|
1042
|
+
the caller can manage, and call :meth:`release` the instant the answer
|
|
1043
|
+
needed has been read.
|
|
1044
|
+
|
|
1045
|
+
The same two things :class:`RemoteDesktopSession` has to release do not
|
|
1046
|
+
release themselves here either -- the portal session outlives this
|
|
1047
|
+
object, and the EIS fd is owned once read. :meth:`close` (or the
|
|
1048
|
+
context-manager form) does both. Unlike :class:`RemoteDesktopSession`,
|
|
1049
|
+
an *active* capture must additionally be handed back explicitly with
|
|
1050
|
+
:meth:`release` before :meth:`close` -- closing the session without it
|
|
1051
|
+
is exactly the failure mode the exclusivity paragraph above warns
|
|
1052
|
+
about, and this cannot release on a caller's behalf during cleanup
|
|
1053
|
+
without risking racing a capture that only just started.
|
|
1054
|
+
|
|
1055
|
+
**Never live-tested.** Every other class in this module that talks to a
|
|
1056
|
+
real portal carries a hand-verification note in its own docstring; this
|
|
1057
|
+
one does not, because verifying it means a human clicking through the
|
|
1058
|
+
consent dialog *and* accepting that their pointer will be diverted away
|
|
1059
|
+
from their own desktop for the length of the test -- not something to
|
|
1060
|
+
trigger without asking first, unlike everything else here. Designed
|
|
1061
|
+
against ``/usr/share/dbus-1/interfaces/org.freedesktop.portal.
|
|
1062
|
+
InputCapture.xml`` (the shipped portal spec, not the header alone) and
|
|
1063
|
+
unit-tested against a fake connection reproducing that spec's documented
|
|
1064
|
+
shapes; see ``tests/test_inputcapture.py``'s own module docstring for
|
|
1065
|
+
what that does and does not prove.
|
|
1066
|
+
"""
|
|
1067
|
+
|
|
1068
|
+
def __init__(
|
|
1069
|
+
self,
|
|
1070
|
+
connection: Any,
|
|
1071
|
+
session_handle: str,
|
|
1072
|
+
eis_fd: int,
|
|
1073
|
+
restore_token: str | None,
|
|
1074
|
+
busname: str = _BUS_NAME,
|
|
1075
|
+
) -> None:
|
|
1076
|
+
self._connection = connection
|
|
1077
|
+
self._session_handle: str | None = session_handle
|
|
1078
|
+
self._eis_fd: int | None = eis_fd
|
|
1079
|
+
self._eis_fd_claimed = False
|
|
1080
|
+
self._busname = busname
|
|
1081
|
+
self._closed = False
|
|
1082
|
+
self.restore_token = restore_token
|
|
1083
|
+
"""The token to pass as ``restore_token=`` on the next call to
|
|
1084
|
+
avoid re-prompting, or ``None`` -- see
|
|
1085
|
+
`RemoteDesktopSession.restore_token`, which this mirrors exactly."""
|
|
1086
|
+
|
|
1087
|
+
@property
|
|
1088
|
+
def session_handle(self) -> str:
|
|
1089
|
+
"""The object path this session is addressed by.
|
|
1090
|
+
|
|
1091
|
+
Exposed (unlike `RemoteDesktopSession`, which has no reason to)
|
|
1092
|
+
because :meth:`wait_for_activation` and :meth:`wait_for_deactivation`
|
|
1093
|
+
are scoped to one session's own signals, and a caller building
|
|
1094
|
+
something this module does not -- watching several sessions on one
|
|
1095
|
+
`GLib.MainContext`, say -- needs it to tell them apart.
|
|
1096
|
+
"""
|
|
1097
|
+
if self._session_handle is None:
|
|
1098
|
+
raise PortalError("the session is closed")
|
|
1099
|
+
return self._session_handle
|
|
1100
|
+
|
|
1101
|
+
@property
|
|
1102
|
+
def eis_fd(self) -> int:
|
|
1103
|
+
"""The fd to pass to a passive ``libei.ei.Receiver`` context.
|
|
1104
|
+
|
|
1105
|
+
Reading this transfers ownership to the caller, exactly as
|
|
1106
|
+
`RemoteDesktopSession.eis_fd` does for the sender side -- see that
|
|
1107
|
+
property's docstring for the ownership rule this mirrors.
|
|
1108
|
+
"""
|
|
1109
|
+
if self._eis_fd is None:
|
|
1110
|
+
raise PortalError("the session is closed; its EIS fd is gone")
|
|
1111
|
+
self._eis_fd_claimed = True
|
|
1112
|
+
return self._eis_fd
|
|
1113
|
+
|
|
1114
|
+
def _plain_call(self, method: str, *, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
1115
|
+
"""Call ``Enable`` or ``Disable``: no options, no reply, no Request.
|
|
1116
|
+
|
|
1117
|
+
Both take effect (or fail on the D-Bus itself) synchronously, with
|
|
1118
|
+
no consent dialog and so no ``Response`` signal to wait for --
|
|
1119
|
+
unlike ``Start``, ``GetZones`` and ``SetPointerBarriers``, which go
|
|
1120
|
+
through `_request`. ``Release`` is this same call shape but needs
|
|
1121
|
+
an options vardict of its own, so it is not built on this.
|
|
1122
|
+
"""
|
|
1123
|
+
gio_modules = _gio()
|
|
1124
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1125
|
+
raise PortalError("PyGObject is not installed")
|
|
1126
|
+
Gio, GLib = gio_modules
|
|
1127
|
+
_call_sync(
|
|
1128
|
+
self._connection,
|
|
1129
|
+
Gio,
|
|
1130
|
+
GLib,
|
|
1131
|
+
self._busname,
|
|
1132
|
+
_OBJECT_PATH,
|
|
1133
|
+
_INPUT_CAPTURE,
|
|
1134
|
+
method,
|
|
1135
|
+
GLib.Variant("(oa{sv})", (self.session_handle, {})),
|
|
1136
|
+
None,
|
|
1137
|
+
int(timeout * 1000),
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def enable(self, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
1141
|
+
"""Allow capture to be triggered from now on.
|
|
1142
|
+
|
|
1143
|
+
Does not itself divert any input -- it only arms whatever pointer
|
|
1144
|
+
barriers :meth:`set_pointer_barriers` set up. The compositor decides
|
|
1145
|
+
if and when a barrier is actually crossed; :meth:`wait_for_activation`
|
|
1146
|
+
is how a caller finds out that it was.
|
|
1147
|
+
"""
|
|
1148
|
+
self._plain_call("Enable", timeout=timeout)
|
|
1149
|
+
|
|
1150
|
+
def disable(self, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
1151
|
+
"""Prevent capture from being triggered again until :meth:`enable`.
|
|
1152
|
+
|
|
1153
|
+
Does not end a capture already in progress -- see :meth:`release`
|
|
1154
|
+
for that -- and, per the portal spec, emits no signal of its own
|
|
1155
|
+
even though it can leave a `Deactivated` still in flight for a
|
|
1156
|
+
capture that was already active when this was called.
|
|
1157
|
+
"""
|
|
1158
|
+
self._plain_call("Disable", timeout=timeout)
|
|
1159
|
+
|
|
1160
|
+
def release(
|
|
1161
|
+
self,
|
|
1162
|
+
activation_id: int,
|
|
1163
|
+
cursor_position: tuple[float, float] | None = None,
|
|
1164
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
1165
|
+
) -> None:
|
|
1166
|
+
"""Hand an active capture back to the desktop.
|
|
1167
|
+
|
|
1168
|
+
Call this as soon as whatever the capture was opened to read has
|
|
1169
|
+
been read -- see the exclusivity paragraph on the class docstring.
|
|
1170
|
+
``cursor_position`` is only ever a suggestion to the compositor for
|
|
1171
|
+
where to place the pointer on hand-back, in the coordinate space
|
|
1172
|
+
:meth:`zones` reports; omitted, the compositor decides on its own.
|
|
1173
|
+
"""
|
|
1174
|
+
gio_modules = _gio()
|
|
1175
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1176
|
+
raise PortalError("PyGObject is not installed")
|
|
1177
|
+
Gio, GLib = gio_modules
|
|
1178
|
+
options: dict[str, Any] = {"activation_id": GLib.Variant("u", activation_id)}
|
|
1179
|
+
if cursor_position is not None:
|
|
1180
|
+
options["cursor_position"] = GLib.Variant("(dd)", cursor_position)
|
|
1181
|
+
_call_sync(
|
|
1182
|
+
self._connection,
|
|
1183
|
+
Gio,
|
|
1184
|
+
GLib,
|
|
1185
|
+
self._busname,
|
|
1186
|
+
_OBJECT_PATH,
|
|
1187
|
+
_INPUT_CAPTURE,
|
|
1188
|
+
"Release",
|
|
1189
|
+
GLib.Variant("(oa{sv})", (self.session_handle, options)),
|
|
1190
|
+
None,
|
|
1191
|
+
int(timeout * 1000),
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
def zones(
|
|
1195
|
+
self, timeout: float = _DEFAULT_TIMEOUT
|
|
1196
|
+
) -> tuple[int, list[tuple[int, int, int, int]]]:
|
|
1197
|
+
"""The session's current input zones, as ``(zone_set, zones)``.
|
|
1198
|
+
|
|
1199
|
+
Each zone is ``(width, height, x, y)``, that exact order -- the
|
|
1200
|
+
wire order the spec documents, not the ``(x, y, width, height)``
|
|
1201
|
+
order `pyguitest.Screen` uses; a caller bridging the two must
|
|
1202
|
+
reorder, not assume they match. ``zone_set`` must be passed back to
|
|
1203
|
+
:meth:`set_pointer_barriers` unchanged, or the call fails: the
|
|
1204
|
+
portal uses it to detect a caller acting on a stale zone layout
|
|
1205
|
+
(a monitor unplugged since the last call, say).
|
|
1206
|
+
"""
|
|
1207
|
+
gio_modules = _gio()
|
|
1208
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1209
|
+
raise PortalError("PyGObject is not installed")
|
|
1210
|
+
Gio, GLib = gio_modules
|
|
1211
|
+
code, results = _request(
|
|
1212
|
+
self._connection,
|
|
1213
|
+
Gio,
|
|
1214
|
+
GLib,
|
|
1215
|
+
self._busname,
|
|
1216
|
+
_INPUT_CAPTURE,
|
|
1217
|
+
"GetZones",
|
|
1218
|
+
"(oa{sv})",
|
|
1219
|
+
(self.session_handle,),
|
|
1220
|
+
{},
|
|
1221
|
+
timeout,
|
|
1222
|
+
)
|
|
1223
|
+
if code != 0:
|
|
1224
|
+
raise PortalDeniedError("GetZones")
|
|
1225
|
+
zone_set = results.get("zone_set", 0)
|
|
1226
|
+
zones = [tuple(z) for z in results.get("zones", [])]
|
|
1227
|
+
return int(zone_set), zones
|
|
1228
|
+
|
|
1229
|
+
def set_pointer_barriers(
|
|
1230
|
+
self,
|
|
1231
|
+
barriers: list[tuple[int, int, int, int, int]],
|
|
1232
|
+
zone_set: int,
|
|
1233
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
1234
|
+
) -> list[int]:
|
|
1235
|
+
"""Arm pointer barriers; returns the subset the compositor refused.
|
|
1236
|
+
|
|
1237
|
+
Each barrier is ``(barrier_id, x1, y1, x2, y2)`` -- a non-zero id
|
|
1238
|
+
the caller chooses (it comes back on the `Activated` signal that
|
|
1239
|
+
the barrier triggered), then the line's endpoints, which must be
|
|
1240
|
+
purely horizontal (``y1 == y2``) or purely vertical (``x1 == x2``)
|
|
1241
|
+
and must sit on the outside edge of the zone union -- see the
|
|
1242
|
+
portal spec for the exact placement rules; this does not validate
|
|
1243
|
+
them, the compositor does, at this call.
|
|
1244
|
+
|
|
1245
|
+
**Calling this suspends the session.** The spec is explicit: after
|
|
1246
|
+
this call the caller must call :meth:`enable` again, even if
|
|
1247
|
+
capture was already enabled before. Passing an empty list clears
|
|
1248
|
+
every barrier already set.
|
|
1249
|
+
"""
|
|
1250
|
+
gio_modules = _gio()
|
|
1251
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1252
|
+
raise PortalError("PyGObject is not installed")
|
|
1253
|
+
Gio, GLib = gio_modules
|
|
1254
|
+
packed = [
|
|
1255
|
+
{
|
|
1256
|
+
"barrier_id": GLib.Variant("u", barrier_id),
|
|
1257
|
+
"position": GLib.Variant("(iiii)", (x1, y1, x2, y2)),
|
|
1258
|
+
}
|
|
1259
|
+
for barrier_id, x1, y1, x2, y2 in barriers
|
|
1260
|
+
]
|
|
1261
|
+
code, results = _request(
|
|
1262
|
+
self._connection,
|
|
1263
|
+
Gio,
|
|
1264
|
+
GLib,
|
|
1265
|
+
self._busname,
|
|
1266
|
+
_INPUT_CAPTURE,
|
|
1267
|
+
"SetPointerBarriers",
|
|
1268
|
+
"(oa{sv}aa{sv}u)",
|
|
1269
|
+
(self.session_handle,),
|
|
1270
|
+
{},
|
|
1271
|
+
timeout,
|
|
1272
|
+
trailing_args=(packed, zone_set),
|
|
1273
|
+
)
|
|
1274
|
+
if code != 0:
|
|
1275
|
+
raise PortalDeniedError("SetPointerBarriers")
|
|
1276
|
+
return list(results.get("failed_barriers", []))
|
|
1277
|
+
|
|
1278
|
+
def wait_for_activation(self, timeout: float | None = None) -> Activation:
|
|
1279
|
+
"""Block until the compositor activates capture, or ``timeout``.
|
|
1280
|
+
|
|
1281
|
+
Only returns once a real barrier crossing has been reported --
|
|
1282
|
+
which, on hardware, means a human moved a physical pointer across
|
|
1283
|
+
one. There is no way to trigger this synthetically (see the class
|
|
1284
|
+
docstring's third paragraph), so this call can legitimately hang
|
|
1285
|
+
until someone does that, and `timeout=None` -- the default -- waits
|
|
1286
|
+
for exactly as long as that takes. Pass a real number for any
|
|
1287
|
+
caller that would rather fail than sit there.
|
|
1288
|
+
"""
|
|
1289
|
+
gio_modules = _gio()
|
|
1290
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1291
|
+
raise PortalError("PyGObject is not installed")
|
|
1292
|
+
Gio, GLib = gio_modules
|
|
1293
|
+
_session_handle, options = _wait_for_signal(
|
|
1294
|
+
self._connection,
|
|
1295
|
+
Gio,
|
|
1296
|
+
GLib,
|
|
1297
|
+
self._busname,
|
|
1298
|
+
_INPUT_CAPTURE,
|
|
1299
|
+
"Activated",
|
|
1300
|
+
self.session_handle,
|
|
1301
|
+
timeout,
|
|
1302
|
+
)
|
|
1303
|
+
cursor = options.get("cursor_position")
|
|
1304
|
+
return Activation(
|
|
1305
|
+
activation_id=int(options.get("activation_id", 0)),
|
|
1306
|
+
cursor_position=tuple(cursor) if cursor is not None else None,
|
|
1307
|
+
barrier_id=options.get("barrier_id"),
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
def wait_for_deactivation(self, timeout: float | None = None) -> int:
|
|
1311
|
+
"""Block until the current capture ends, returning its activation_id."""
|
|
1312
|
+
gio_modules = _gio()
|
|
1313
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1314
|
+
raise PortalError("PyGObject is not installed")
|
|
1315
|
+
Gio, GLib = gio_modules
|
|
1316
|
+
_session_handle, options = _wait_for_signal(
|
|
1317
|
+
self._connection,
|
|
1318
|
+
Gio,
|
|
1319
|
+
GLib,
|
|
1320
|
+
self._busname,
|
|
1321
|
+
_INPUT_CAPTURE,
|
|
1322
|
+
"Deactivated",
|
|
1323
|
+
self.session_handle,
|
|
1324
|
+
timeout,
|
|
1325
|
+
)
|
|
1326
|
+
return int(options.get("activation_id", 0))
|
|
1327
|
+
|
|
1328
|
+
def close(self) -> None:
|
|
1329
|
+
"""End the portal session, and close the EIS fd if unclaimed.
|
|
1330
|
+
|
|
1331
|
+
Idempotent, and deliberately does not call :meth:`release` first --
|
|
1332
|
+
see the class docstring for why an *active* capture must be
|
|
1333
|
+
released explicitly before this, not folded into cleanup here.
|
|
1334
|
+
"""
|
|
1335
|
+
if self._closed:
|
|
1336
|
+
return
|
|
1337
|
+
self._closed = True
|
|
1338
|
+
if self._eis_fd is not None and not self._eis_fd_claimed:
|
|
1339
|
+
try:
|
|
1340
|
+
os.close(self._eis_fd)
|
|
1341
|
+
except OSError as exc:
|
|
1342
|
+
logger.debug("closing the EIS fd failed: %s", exc)
|
|
1343
|
+
self._eis_fd = None
|
|
1344
|
+
if self._session_handle is None or self._connection is None:
|
|
1345
|
+
return
|
|
1346
|
+
gio_modules = _gio()
|
|
1347
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
1348
|
+
return
|
|
1349
|
+
Gio, GLib = gio_modules
|
|
1350
|
+
try:
|
|
1351
|
+
_close_session(
|
|
1352
|
+
self._connection,
|
|
1353
|
+
Gio,
|
|
1354
|
+
GLib,
|
|
1355
|
+
self._busname,
|
|
1356
|
+
self._session_handle,
|
|
1357
|
+
)
|
|
1358
|
+
finally:
|
|
1359
|
+
self._session_handle = None
|
|
1360
|
+
self._connection = None
|
|
1361
|
+
|
|
1362
|
+
def __enter__(self) -> InputCaptureSession:
|
|
1363
|
+
return self
|
|
1364
|
+
|
|
1365
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
1366
|
+
self.close()
|
|
1367
|
+
|
|
1368
|
+
def __del__(self) -> None:
|
|
1369
|
+
# See RemoteDesktopSession.__del__ for why this closes only the fd.
|
|
1370
|
+
if getattr(self, "_eis_fd_claimed", True):
|
|
1371
|
+
return
|
|
1372
|
+
eis_fd = getattr(self, "_eis_fd", None)
|
|
1373
|
+
if eis_fd is not None:
|
|
1374
|
+
try:
|
|
1375
|
+
os.close(eis_fd)
|
|
1376
|
+
except OSError:
|
|
1377
|
+
pass
|
|
1378
|
+
|
|
1379
|
+
@classmethod
|
|
1380
|
+
def negotiate(
|
|
1381
|
+
cls,
|
|
1382
|
+
*,
|
|
1383
|
+
capabilities: DeviceType = DeviceType.ALL_DEVICES,
|
|
1384
|
+
connection: Any = None,
|
|
1385
|
+
persist_mode: PersistMode = PersistMode.NONE,
|
|
1386
|
+
restore_token: str | None = None,
|
|
1387
|
+
busname: str = _BUS_NAME,
|
|
1388
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
1389
|
+
) -> InputCaptureSession:
|
|
1390
|
+
"""Negotiate an InputCapture portal session and connect it to EIS.
|
|
1391
|
+
|
|
1392
|
+
Blocks until ``CreateSession2`` -> ``Start`` -> ``ConnectToEIS``
|
|
1393
|
+
resolves, prompting the user for consent along the way unless
|
|
1394
|
+
``restore_token`` lets the portal skip that. Raises
|
|
1395
|
+
:class:`PortalVersionError` if the compositor's InputCapture portal
|
|
1396
|
+
predates ``CreateSession2`` (needs v2+ -- this module never speaks
|
|
1397
|
+
the deprecated v1 ``CreateSession``), :class:`PortalDeniedError` if
|
|
1398
|
+
``Start`` is declined, and :class:`PortalTimeoutError` if any one
|
|
1399
|
+
round trip exceeds ``timeout`` seconds.
|
|
1400
|
+
|
|
1401
|
+
Returns before anything is actually captured: :meth:`set_pointer_barriers`
|
|
1402
|
+
and :meth:`enable` still have to be called, and even then nothing
|
|
1403
|
+
happens until the compositor decides a barrier was crossed -- see
|
|
1404
|
+
:meth:`wait_for_activation`. Nothing about negotiating this session
|
|
1405
|
+
diverts input on its own.
|
|
1406
|
+
|
|
1407
|
+
``capabilities`` reuses :class:`DeviceType` -- the InputCapture
|
|
1408
|
+
portal's own bitmask documents the identical three bits
|
|
1409
|
+
(``KEYBOARD``, ``POINTER``, ``TOUCHSCREEN``) for the same purpose,
|
|
1410
|
+
selecting which device classes this session may ever capture.
|
|
1411
|
+
``persist_mode`` and ``restore_token`` work exactly as they do for
|
|
1412
|
+
:meth:`RemoteDesktopSession.negotiate` -- see that method.
|
|
1413
|
+
"""
|
|
1414
|
+
if restore_token is not None and persist_mode == PersistMode.NONE:
|
|
1415
|
+
raise ValueError(
|
|
1416
|
+
"restore_token was given with persist_mode=NONE: the portal "
|
|
1417
|
+
"consumes a restore token on use and only issues a new one "
|
|
1418
|
+
"when persistence is requested, so this would spend the "
|
|
1419
|
+
"saved token and hand back None. Pass a persist_mode too."
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
gio_modules = _gio()
|
|
1423
|
+
if gio_modules is None:
|
|
1424
|
+
raise PortalError(
|
|
1425
|
+
"PyGObject is not installed; libei.portal needs it to "
|
|
1426
|
+
"negotiate an InputCapture portal session "
|
|
1427
|
+
"(pip install 'python-libei[portal]')"
|
|
1428
|
+
)
|
|
1429
|
+
Gio, GLib = gio_modules
|
|
1430
|
+
|
|
1431
|
+
if connection is None:
|
|
1432
|
+
try:
|
|
1433
|
+
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
1434
|
+
except _glib_error(GLib) as exc:
|
|
1435
|
+
raise PortalError(f"cannot reach the session bus: {exc}") from exc
|
|
1436
|
+
|
|
1437
|
+
version = _input_capture_version(connection, Gio, GLib, busname, timeout)
|
|
1438
|
+
if version < _MIN_INPUT_CAPTURE_VERSION:
|
|
1439
|
+
raise PortalVersionError(
|
|
1440
|
+
f"InputCapture version {version} is too old for "
|
|
1441
|
+
f"CreateSession2 (need {_MIN_INPUT_CAPTURE_VERSION}+)"
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
if capabilities == DeviceType.ALL_DEVICES:
|
|
1445
|
+
types = _ALL_DEVICE_TYPES
|
|
1446
|
+
else:
|
|
1447
|
+
types = capabilities
|
|
1448
|
+
reply = _call_sync(
|
|
1449
|
+
connection,
|
|
1450
|
+
Gio,
|
|
1451
|
+
GLib,
|
|
1452
|
+
busname,
|
|
1453
|
+
_OBJECT_PATH,
|
|
1454
|
+
_INPUT_CAPTURE,
|
|
1455
|
+
"CreateSession2",
|
|
1456
|
+
GLib.Variant(
|
|
1457
|
+
"(a{sv})",
|
|
1458
|
+
({"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},),
|
|
1459
|
+
),
|
|
1460
|
+
None,
|
|
1461
|
+
int(timeout * 1000),
|
|
1462
|
+
)
|
|
1463
|
+
(results,) = reply.unpack()
|
|
1464
|
+
session_handle = results.get("session_handle")
|
|
1465
|
+
if not isinstance(session_handle, str):
|
|
1466
|
+
raise PortalError("CreateSession2 returned no session_handle")
|
|
1467
|
+
|
|
1468
|
+
# Past this point a session exists inside xdg-desktop-portal, and
|
|
1469
|
+
# nothing else can close it -- see RemoteDesktopSession.negotiate's
|
|
1470
|
+
# identical reasoning, which this mirrors line for line.
|
|
1471
|
+
try:
|
|
1472
|
+
options: dict[str, Any] = {"capabilities": GLib.Variant("u", int(types))}
|
|
1473
|
+
if persist_mode != PersistMode.NONE:
|
|
1474
|
+
options["persist_mode"] = GLib.Variant("u", int(persist_mode))
|
|
1475
|
+
if restore_token is not None:
|
|
1476
|
+
options["restore_token"] = GLib.Variant("s", restore_token)
|
|
1477
|
+
code, start_results = _request(
|
|
1478
|
+
connection,
|
|
1479
|
+
Gio,
|
|
1480
|
+
GLib,
|
|
1481
|
+
busname,
|
|
1482
|
+
_INPUT_CAPTURE,
|
|
1483
|
+
"Start",
|
|
1484
|
+
"(osa{sv})",
|
|
1485
|
+
(session_handle, ""),
|
|
1486
|
+
options,
|
|
1487
|
+
timeout,
|
|
1488
|
+
)
|
|
1489
|
+
if code != 0:
|
|
1490
|
+
raise PortalDeniedError(
|
|
1491
|
+
"Start", "the user declined the input-capture consent dialog"
|
|
1492
|
+
)
|
|
1493
|
+
new_restore_token = start_results.get("restore_token")
|
|
1494
|
+
|
|
1495
|
+
eis_fd = _call_for_fd(
|
|
1496
|
+
connection,
|
|
1497
|
+
Gio,
|
|
1498
|
+
GLib,
|
|
1499
|
+
busname,
|
|
1500
|
+
_INPUT_CAPTURE,
|
|
1501
|
+
"ConnectToEIS",
|
|
1502
|
+
session_handle,
|
|
1503
|
+
timeout,
|
|
1504
|
+
)
|
|
1505
|
+
except BaseException:
|
|
1506
|
+
# BaseException, not Exception: Start blocks on a human
|
|
1507
|
+
# answering a consent dialog, so Ctrl-C during that wait is a
|
|
1508
|
+
# routine way out of this function -- and it strands an
|
|
1509
|
+
# approved session exactly as a decline does.
|
|
1510
|
+
_close_session(connection, Gio, GLib, busname, session_handle)
|
|
1511
|
+
raise
|
|
1512
|
+
|
|
1513
|
+
return cls(connection, session_handle, eis_fd, new_restore_token, busname)
|