python-libei 0.5.0__py3-none-any.whl → 0.5.2__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/__init__.py +5 -2
- libei/ei.py +71 -15
- libei/eis.py +65 -17
- libei/oeffis.py +7 -2
- libei/portal.py +241 -76
- {python_libei-0.5.0.dist-info → python_libei-0.5.2.dist-info}/METADATA +13 -28
- python_libei-0.5.2.dist-info/RECORD +17 -0
- python_libei-0.5.0.dist-info/RECORD +0 -17
- {python_libei-0.5.0.dist-info → python_libei-0.5.2.dist-info}/WHEEL +0 -0
- {python_libei-0.5.0.dist-info → python_libei-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {python_libei-0.5.0.dist-info → python_libei-0.5.2.dist-info}/top_level.txt +0 -0
libei/__init__.py
CHANGED
libei/_capi/__init__.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
"""Low-level ctypes bindings.
|
|
2
|
-
|
|
1
|
+
"""Low-level ctypes bindings.
|
|
2
|
+
|
|
3
|
+
Not part of the public API -- use ``libei.ei``, ``libei.eis`` and
|
|
4
|
+
``libei.oeffis`` instead.
|
|
5
|
+
"""
|
|
3
6
|
|
|
4
7
|
from . import libei, libeis, liboeffis
|
|
5
8
|
|
libei/ei.py
CHANGED
|
@@ -87,6 +87,7 @@ class Error(Exception):
|
|
|
87
87
|
"""
|
|
88
88
|
|
|
89
89
|
def __init__(self, message: str, errno: int | None = None) -> None:
|
|
90
|
+
"""Record the failure message and, where libei reported one, errno."""
|
|
90
91
|
super().__init__(message)
|
|
91
92
|
self.message = message
|
|
92
93
|
self.errno = errno
|
|
@@ -199,6 +200,12 @@ class _LogPriority(enum.IntEnum):
|
|
|
199
200
|
|
|
200
201
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
201
202
|
class XkbModifiersEvent:
|
|
203
|
+
"""XKB modifier state from a KEYBOARD_MODIFIERS event.
|
|
204
|
+
|
|
205
|
+
``depressed``/``latched``/``locked`` are XKB's own mod-state bitmasks;
|
|
206
|
+
``group`` is the active keyboard layout group.
|
|
207
|
+
"""
|
|
208
|
+
|
|
202
209
|
depressed: int
|
|
203
210
|
latched: int
|
|
204
211
|
locked: int
|
|
@@ -207,48 +214,76 @@ class XkbModifiersEvent:
|
|
|
207
214
|
|
|
208
215
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
209
216
|
class KeyEvent:
|
|
217
|
+
"""Key code and press state from a KEYBOARD_KEY event.
|
|
218
|
+
|
|
219
|
+
``key`` is a Linux ``KEY_*`` code, the same numbering
|
|
220
|
+
:meth:`Device.keyboard_key` sends.
|
|
221
|
+
"""
|
|
222
|
+
|
|
210
223
|
key: int
|
|
211
224
|
is_press: bool
|
|
212
225
|
|
|
213
226
|
|
|
214
227
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
215
228
|
class ButtonEvent:
|
|
229
|
+
"""Button code and press state from a BUTTON_BUTTON event.
|
|
230
|
+
|
|
231
|
+
``button`` is a Linux ``BTN_*`` code, the same numbering
|
|
232
|
+
:meth:`Device.button` sends.
|
|
233
|
+
"""
|
|
234
|
+
|
|
216
235
|
button: int
|
|
217
236
|
is_press: bool
|
|
218
237
|
|
|
219
238
|
|
|
220
239
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
221
240
|
class PointerEvent:
|
|
241
|
+
"""Relative motion deltas, in logical pixels, from a POINTER_MOTION event."""
|
|
242
|
+
|
|
222
243
|
dx: float
|
|
223
244
|
dy: float
|
|
224
245
|
|
|
225
246
|
|
|
226
247
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
227
248
|
class PointerAbsoluteEvent:
|
|
249
|
+
"""Absolute position from a POINTER_MOTION_ABSOLUTE event.
|
|
250
|
+
|
|
251
|
+
In the logical pixel space of the :class:`Region` the emitting device
|
|
252
|
+
covers -- see :meth:`Event.pointer_absolute_event`.
|
|
253
|
+
"""
|
|
254
|
+
|
|
228
255
|
x: float
|
|
229
256
|
y: float
|
|
230
257
|
|
|
231
258
|
|
|
232
259
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
233
260
|
class ScrollEvent:
|
|
261
|
+
"""Smooth scroll deltas from a SCROLL_DELTA event."""
|
|
262
|
+
|
|
234
263
|
dx: float
|
|
235
264
|
dy: float
|
|
236
265
|
|
|
237
266
|
|
|
238
267
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
239
268
|
class ScrollDiscreteEvent:
|
|
269
|
+
"""Detent scroll deltas (120 per detent) from a SCROLL_DISCRETE event."""
|
|
270
|
+
|
|
240
271
|
dx: int
|
|
241
272
|
dy: int
|
|
242
273
|
|
|
243
274
|
|
|
244
275
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
245
276
|
class ScrollStopEvent:
|
|
277
|
+
"""Which axes stopped scrolling, from a SCROLL_STOP/SCROLL_CANCEL event."""
|
|
278
|
+
|
|
246
279
|
stop_x: bool
|
|
247
280
|
stop_y: bool
|
|
248
281
|
|
|
249
282
|
|
|
250
283
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
251
284
|
class TouchEvent:
|
|
285
|
+
"""Touch id and position from a TOUCH_DOWN or TOUCH_MOTION event."""
|
|
286
|
+
|
|
252
287
|
touchid: int
|
|
253
288
|
x: float
|
|
254
289
|
y: float
|
|
@@ -256,17 +291,26 @@ class TouchEvent:
|
|
|
256
291
|
|
|
257
292
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
258
293
|
class TouchUpEvent:
|
|
294
|
+
"""Touch id and cancellation flag from a TOUCH_UP event.
|
|
295
|
+
|
|
296
|
+
See :meth:`Event.touch_up_event` for when ``is_cancel`` is trustworthy.
|
|
297
|
+
"""
|
|
298
|
+
|
|
259
299
|
touchid: int
|
|
260
300
|
is_cancel: bool
|
|
261
301
|
|
|
262
302
|
|
|
263
303
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
264
304
|
class TextUtf8Event:
|
|
305
|
+
"""UTF-8 text carried by a TEXT_UTF8 event."""
|
|
306
|
+
|
|
265
307
|
text: str
|
|
266
308
|
|
|
267
309
|
|
|
268
310
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
269
311
|
class TextKeysymEvent:
|
|
312
|
+
"""Keysym and press state from a TEXT_KEYSYM event."""
|
|
313
|
+
|
|
270
314
|
keysym: int
|
|
271
315
|
is_press: bool
|
|
272
316
|
|
|
@@ -545,9 +589,10 @@ class Device(CObject):
|
|
|
545
589
|
return self
|
|
546
590
|
|
|
547
591
|
def frame(self, timestamp: int | None = None) -> Device:
|
|
548
|
-
"""Commit the events queued since the last frame as one logical
|
|
549
|
-
|
|
550
|
-
time.
|
|
592
|
+
"""Commit the events queued since the last frame as one logical hardware event.
|
|
593
|
+
|
|
594
|
+
``timestamp`` defaults to the context's current time.
|
|
595
|
+
"""
|
|
551
596
|
if timestamp is None:
|
|
552
597
|
timestamp = _capi.libei.now(_capi.libei.device_get_context(self))
|
|
553
598
|
_capi.libei.device_frame(self, timestamp)
|
|
@@ -564,8 +609,10 @@ class Device(CObject):
|
|
|
564
609
|
return self
|
|
565
610
|
|
|
566
611
|
def button(self, button: int, is_press: bool) -> Device:
|
|
567
|
-
"""Queue a button press or release.
|
|
568
|
-
|
|
612
|
+
"""Queue a button press or release.
|
|
613
|
+
|
|
614
|
+
``button`` is a Linux ``BTN_*`` code (e.g. ``0x110`` for ``BTN_LEFT``).
|
|
615
|
+
"""
|
|
569
616
|
_capi.libei.device_button_button(self, button, is_press)
|
|
570
617
|
return self
|
|
571
618
|
|
|
@@ -760,8 +807,11 @@ class Event(CObject):
|
|
|
760
807
|
|
|
761
808
|
@property
|
|
762
809
|
def event_type(self) -> EventType | int:
|
|
763
|
-
"""The event's type
|
|
764
|
-
|
|
810
|
+
"""The event's type.
|
|
811
|
+
|
|
812
|
+
Returns a raw int for a value newer than this package's
|
|
813
|
+
:class:`EventType` table -- see its docstring.
|
|
814
|
+
"""
|
|
765
815
|
raw = _capi.libei.event_get_type(self)
|
|
766
816
|
try:
|
|
767
817
|
return EventType(raw)
|
|
@@ -1009,11 +1059,14 @@ class Context(CObject):
|
|
|
1009
1059
|
_wrappable = False
|
|
1010
1060
|
|
|
1011
1061
|
def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1062
|
+
"""Wrap a freshly created ``struct ei *`` and arm its log handler.
|
|
1063
|
+
|
|
1064
|
+
_adopt is accepted and forwarded for signature consistency with
|
|
1065
|
+
CObject, but with _wrappable = False, _get_or_create() never
|
|
1066
|
+
actually reaches this constructor -- Context (and Sender/
|
|
1067
|
+
Receiver) are always built directly via cls(cls._new()) in
|
|
1068
|
+
create_for_fd()/create_for_socket().
|
|
1069
|
+
"""
|
|
1017
1070
|
super().__init__(pointer, _adopt=_adopt)
|
|
1018
1071
|
self._name: str | None = None
|
|
1019
1072
|
_capi.libei.log_set_handler(self, _log_handler)
|
|
@@ -1081,7 +1134,8 @@ class Context(CObject):
|
|
|
1081
1134
|
"""Use an already-connected socket as the transport.
|
|
1082
1135
|
|
|
1083
1136
|
libei takes ownership of a raw int fd and closes it itself; a file
|
|
1084
|
-
object is duplicated first, so the caller's own object stays valid.
|
|
1137
|
+
object is duplicated first, so the caller's own object stays valid.
|
|
1138
|
+
"""
|
|
1085
1139
|
# ei_setup_backend_fd() takes ownership of the fd and will close it
|
|
1086
1140
|
# itself. A raw int is assumed to already be one the caller is
|
|
1087
1141
|
# handing off (matching what eis.Eis.add_client()/oeffis.eis_fd
|
|
@@ -1099,7 +1153,8 @@ class Context(CObject):
|
|
|
1099
1153
|
"""Connect to an EIS socket by path.
|
|
1100
1154
|
|
|
1101
1155
|
``None`` uses ``$LIBEI_SOCKET``; a relative path is resolved
|
|
1102
|
-
against ``$XDG_RUNTIME_DIR``.
|
|
1156
|
+
against ``$XDG_RUNTIME_DIR``.
|
|
1157
|
+
"""
|
|
1103
1158
|
encoded = os.fspath(path).encode("utf-8") if path else None
|
|
1104
1159
|
err = _capi.libei.setup_backend_socket(self, encoded)
|
|
1105
1160
|
if err < 0:
|
|
@@ -1162,7 +1217,8 @@ class Context(CObject):
|
|
|
1162
1217
|
"""Read from the connection and queue any events that arrive.
|
|
1163
1218
|
|
|
1164
1219
|
Call this before iterating :attr:`events`, which only drains what
|
|
1165
|
-
is already queued.
|
|
1220
|
+
is already queued.
|
|
1221
|
+
"""
|
|
1166
1222
|
_capi.libei.dispatch(self)
|
|
1167
1223
|
|
|
1168
1224
|
|
libei/eis.py
CHANGED
|
@@ -55,6 +55,7 @@ class Error(Exception):
|
|
|
55
55
|
"""
|
|
56
56
|
|
|
57
57
|
def __init__(self, message: str, errno: int | None = None) -> None:
|
|
58
|
+
"""Record the failure message and, where libeis reported one, errno."""
|
|
58
59
|
super().__init__(message)
|
|
59
60
|
self.message = message
|
|
60
61
|
self.errno = errno
|
|
@@ -166,48 +167,75 @@ class _LogPriority(enum.IntEnum):
|
|
|
166
167
|
|
|
167
168
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
168
169
|
class KeyEvent:
|
|
170
|
+
"""Key code and press state received on a KEYBOARD_KEY event.
|
|
171
|
+
|
|
172
|
+
``key`` is a Linux ``KEY_*`` code, as sent by the client's
|
|
173
|
+
``ei.Device.keyboard_key``.
|
|
174
|
+
"""
|
|
175
|
+
|
|
169
176
|
key: int
|
|
170
177
|
is_press: bool
|
|
171
178
|
|
|
172
179
|
|
|
173
180
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
174
181
|
class ButtonEvent:
|
|
182
|
+
"""Button code and press state received on a BUTTON_BUTTON event.
|
|
183
|
+
|
|
184
|
+
``button`` is a Linux ``BTN_*`` code, as sent by the client's
|
|
185
|
+
``ei.Device.button``.
|
|
186
|
+
"""
|
|
187
|
+
|
|
175
188
|
button: int
|
|
176
189
|
is_press: bool
|
|
177
190
|
|
|
178
191
|
|
|
179
192
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
180
193
|
class PointerEvent:
|
|
194
|
+
"""Relative motion deltas, in logical pixels, from a POINTER_MOTION event."""
|
|
195
|
+
|
|
181
196
|
dx: float
|
|
182
197
|
dy: float
|
|
183
198
|
|
|
184
199
|
|
|
185
200
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
186
201
|
class PointerAbsoluteEvent:
|
|
202
|
+
"""Absolute position from a POINTER_MOTION_ABSOLUTE event.
|
|
203
|
+
|
|
204
|
+
In the logical pixel space of the region the sending device covers.
|
|
205
|
+
"""
|
|
206
|
+
|
|
187
207
|
x: float
|
|
188
208
|
y: float
|
|
189
209
|
|
|
190
210
|
|
|
191
211
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
192
212
|
class ScrollEvent:
|
|
213
|
+
"""Smooth scroll deltas from a SCROLL_DELTA event."""
|
|
214
|
+
|
|
193
215
|
dx: float
|
|
194
216
|
dy: float
|
|
195
217
|
|
|
196
218
|
|
|
197
219
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
198
220
|
class ScrollDiscreteEvent:
|
|
221
|
+
"""Detent scroll deltas (120 per detent) from a SCROLL_DISCRETE event."""
|
|
222
|
+
|
|
199
223
|
dx: int
|
|
200
224
|
dy: int
|
|
201
225
|
|
|
202
226
|
|
|
203
227
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
204
228
|
class ScrollStopEvent:
|
|
229
|
+
"""Which axes stopped scrolling, from a SCROLL_STOP/SCROLL_CANCEL event."""
|
|
230
|
+
|
|
205
231
|
stop_x: bool
|
|
206
232
|
stop_y: bool
|
|
207
233
|
|
|
208
234
|
|
|
209
235
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
210
236
|
class TouchEvent:
|
|
237
|
+
"""Touch id and position from a TOUCH_DOWN or TOUCH_MOTION event."""
|
|
238
|
+
|
|
211
239
|
touchid: int
|
|
212
240
|
x: float
|
|
213
241
|
y: float
|
|
@@ -215,17 +243,23 @@ class TouchEvent:
|
|
|
215
243
|
|
|
216
244
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
217
245
|
class TouchUpEvent:
|
|
246
|
+
"""Touch id and cancellation flag from a TOUCH_UP event."""
|
|
247
|
+
|
|
218
248
|
touchid: int
|
|
219
249
|
is_cancel: bool
|
|
220
250
|
|
|
221
251
|
|
|
222
252
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
223
253
|
class TextUtf8Event:
|
|
254
|
+
"""UTF-8 text carried by a TEXT_UTF8 event."""
|
|
255
|
+
|
|
224
256
|
text: str
|
|
225
257
|
|
|
226
258
|
|
|
227
259
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
228
260
|
class TextKeysymEvent:
|
|
261
|
+
"""Keysym and press state from a TEXT_KEYSYM event."""
|
|
262
|
+
|
|
229
263
|
keysym: int
|
|
230
264
|
is_press: bool
|
|
231
265
|
|
|
@@ -576,8 +610,10 @@ class Device(CObject):
|
|
|
576
610
|
return self
|
|
577
611
|
|
|
578
612
|
def frame(self, timestamp: int | None = None) -> Device:
|
|
579
|
-
"""Commit the events queued since the last frame as one logical
|
|
580
|
-
|
|
613
|
+
"""Commit the events queued since the last frame as one logical hardware event.
|
|
614
|
+
|
|
615
|
+
``timestamp`` defaults to the context's current time.
|
|
616
|
+
"""
|
|
581
617
|
if timestamp is None:
|
|
582
618
|
timestamp = _capi.libeis.now(_capi.libeis.device_get_context(self))
|
|
583
619
|
_capi.libeis.device_frame(self, timestamp)
|
|
@@ -837,8 +873,11 @@ class Event(CObject):
|
|
|
837
873
|
|
|
838
874
|
@property
|
|
839
875
|
def event_type(self) -> EventType | int:
|
|
840
|
-
"""The event's type
|
|
841
|
-
|
|
876
|
+
"""The event's type.
|
|
877
|
+
|
|
878
|
+
Returns a raw int for a value newer than this package's
|
|
879
|
+
:class:`EventType` table -- see its docstring.
|
|
880
|
+
"""
|
|
842
881
|
raw = _capi.libeis.event_get_type(self)
|
|
843
882
|
try:
|
|
844
883
|
return EventType(raw)
|
|
@@ -1069,10 +1108,13 @@ class Eis(CObject):
|
|
|
1069
1108
|
_wrappable = False
|
|
1070
1109
|
|
|
1071
1110
|
def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1111
|
+
"""Wrap a freshly created ``struct eis *`` and arm its log handler.
|
|
1112
|
+
|
|
1113
|
+
_adopt is accepted and forwarded for signature consistency with
|
|
1114
|
+
CObject, but with _wrappable = False, _get_or_create() never
|
|
1115
|
+
actually reaches this constructor -- Eis is always built directly
|
|
1116
|
+
via cls(cls._new()) in create_for_fd().
|
|
1117
|
+
"""
|
|
1076
1118
|
super().__init__(pointer, _adopt=_adopt)
|
|
1077
1119
|
_capi.libeis.log_set_handler(self, _log_handler)
|
|
1078
1120
|
_capi.libeis.log_set_priority(self, _LogPriority.DEBUG)
|
|
@@ -1146,7 +1188,8 @@ class Eis(CObject):
|
|
|
1146
1188
|
"""Read from the connection and queue any events that arrive.
|
|
1147
1189
|
|
|
1148
1190
|
Call this before iterating :attr:`events`, which only drains what
|
|
1149
|
-
is already queued.
|
|
1191
|
+
is already queued.
|
|
1192
|
+
"""
|
|
1150
1193
|
_capi.libeis.dispatch(self)
|
|
1151
1194
|
|
|
1152
1195
|
def add_client(self) -> int:
|
|
@@ -1172,14 +1215,16 @@ class Eis(CObject):
|
|
|
1172
1215
|
|
|
1173
1216
|
@classmethod
|
|
1174
1217
|
def create_for_fd(cls, flags: Sequence[Flag] = ()) -> Eis:
|
|
1175
|
-
"""Create a server using the fd backend
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1218
|
+
"""Create a server using the fd backend.
|
|
1219
|
+
|
|
1220
|
+
The one real compositors use, since it keeps each client's fd
|
|
1221
|
+
private rather than exposing a connectable socket path. Call
|
|
1222
|
+
:meth:`add_client` once per connection you want to accept.
|
|
1179
1223
|
|
|
1180
1224
|
``flags`` are applied here rather than left to the caller because
|
|
1181
1225
|
:meth:`set_flag` has to run before the backend is set up, and this
|
|
1182
|
-
method does both.
|
|
1226
|
+
method does both.
|
|
1227
|
+
"""
|
|
1183
1228
|
server = cls(cls._new())
|
|
1184
1229
|
for flag in flags:
|
|
1185
1230
|
server.set_flag(flag)
|
|
@@ -1190,9 +1235,12 @@ class Eis(CObject):
|
|
|
1190
1235
|
|
|
1191
1236
|
@classmethod
|
|
1192
1237
|
def create_for_socket(cls, path: Path, flags: Sequence[Flag] = ()) -> Eis:
|
|
1193
|
-
"""Create a server listening on a Unix socket
|
|
1194
|
-
|
|
1195
|
-
|
|
1238
|
+
"""Create a server listening on a Unix socket.
|
|
1239
|
+
|
|
1240
|
+
As a compositor would (this is the path a real
|
|
1241
|
+
``ei_setup_backend_socket()`` client connects to). See
|
|
1242
|
+
:meth:`create_for_fd` on ``flags``.
|
|
1243
|
+
"""
|
|
1196
1244
|
server = cls(cls._new())
|
|
1197
1245
|
for flag in flags:
|
|
1198
1246
|
server.set_flag(flag)
|
libei/oeffis.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
"""Pythonic wrapper around liboeffis
|
|
2
|
-
|
|
1
|
+
"""Pythonic wrapper around liboeffis.
|
|
2
|
+
|
|
3
|
+
Negotiates an EIS connection through the
|
|
4
|
+
``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal.
|
|
3
5
|
|
|
4
6
|
This is the path a sandboxed or otherwise non-privileged client uses to get
|
|
5
7
|
an EI socket: it asks the portal, the user is shown a consent dialog, and on
|
|
@@ -52,6 +54,7 @@ class DisconnectedError(Exception):
|
|
|
52
54
|
"""The portal session ended unexpectedly (error, or denied by the user)."""
|
|
53
55
|
|
|
54
56
|
def __init__(self, message: str | None) -> None:
|
|
57
|
+
"""Record why the session ended."""
|
|
55
58
|
super().__init__(message)
|
|
56
59
|
self.message = message
|
|
57
60
|
|
|
@@ -60,6 +63,7 @@ class SessionClosedError(DisconnectedError):
|
|
|
60
63
|
"""The portal explicitly closed the session (not necessarily an error)."""
|
|
61
64
|
|
|
62
65
|
def __init__(self) -> None:
|
|
66
|
+
"""Build the fixed "Session closed" message."""
|
|
63
67
|
super().__init__(message="Session closed")
|
|
64
68
|
|
|
65
69
|
|
|
@@ -100,6 +104,7 @@ class Oeffis:
|
|
|
100
104
|
"""
|
|
101
105
|
|
|
102
106
|
def __init__(self) -> None:
|
|
107
|
+
"""Create the underlying liboeffis context (no portal call yet)."""
|
|
103
108
|
pointer = _capi.liboeffis.new(None)
|
|
104
109
|
if not pointer:
|
|
105
110
|
raise DisconnectedError("oeffis_new() returned NULL")
|
libei/portal.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
"""Negotiate an EIS connection by driving a portal directly over D-Bus
|
|
2
|
-
|
|
1
|
+
"""Negotiate an EIS connection by driving a portal directly over D-Bus.
|
|
2
|
+
|
|
3
|
+
Not through :mod:`libei.oeffis` -- see that module instead for the simpler,
|
|
4
|
+
liboeffis-backed path.
|
|
3
5
|
|
|
4
6
|
Two portals, two directions. :class:`RemoteDesktopSession` negotiates
|
|
5
7
|
``org.freedesktop.portal.RemoteDesktop`` to *inject* input; below it,
|
|
@@ -69,6 +71,7 @@ Three things worth knowing before building on this:
|
|
|
69
71
|
|
|
70
72
|
from __future__ import annotations
|
|
71
73
|
|
|
74
|
+
import contextlib
|
|
72
75
|
import enum
|
|
73
76
|
import logging
|
|
74
77
|
import os
|
|
@@ -104,7 +107,7 @@ _MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
|
|
|
104
107
|
_MIN_INPUT_CAPTURE_VERSION = 2 # CreateSession2 is a v2-only method
|
|
105
108
|
|
|
106
109
|
_DEFAULT_TIMEOUT = 60.0
|
|
107
|
-
"""Seconds to wait for one portal round trip. Generous, because a
|
|
110
|
+
"""Seconds to wait for one portal round trip. Generous, because a user has
|
|
108
111
|
to see and answer the consent dialog `Start` raises -- but bounded, because
|
|
109
112
|
the alternative is a caller wedged forever if the portal dies after
|
|
110
113
|
accepting the call and before sending its `Response`."""
|
|
@@ -156,11 +159,12 @@ class PortalTimeoutError(PortalError):
|
|
|
156
159
|
|
|
157
160
|
Distinct from a decline: the portal accepted the call and then never
|
|
158
161
|
sent its ``Response`` signal. Most often the consent dialog is simply
|
|
159
|
-
still waiting for a
|
|
162
|
+
still waiting for a user, so raise the timeout rather than treating
|
|
160
163
|
this as a failure if that is expected.
|
|
161
164
|
"""
|
|
162
165
|
|
|
163
166
|
def __init__(self, step: str, timeout: float) -> None:
|
|
167
|
+
"""Name which step timed out and after how long."""
|
|
164
168
|
super().__init__(f"{step} did not answer within {timeout:g}s")
|
|
165
169
|
self.step = step
|
|
166
170
|
self.timeout = timeout
|
|
@@ -175,6 +179,7 @@ class PortalDeniedError(PortalError):
|
|
|
175
179
|
"""
|
|
176
180
|
|
|
177
181
|
def __init__(self, step: str, message: str | None = None) -> None:
|
|
182
|
+
"""Name which step was refused and, where the portal gave one, why."""
|
|
178
183
|
super().__init__(message or f"{step} was not approved")
|
|
179
184
|
self.step = step
|
|
180
185
|
self.message = message
|
|
@@ -481,10 +486,16 @@ def _request(
|
|
|
481
486
|
try:
|
|
482
487
|
loop.run()
|
|
483
488
|
finally:
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
489
|
+
# on_timeout() returns False, which is GLib's own signal to
|
|
490
|
+
# deregister a fired one-shot source -- removing it again
|
|
491
|
+
# here trips a real "Source ID N was not found when
|
|
492
|
+
# attempting to remove it" warning, the same bug already
|
|
493
|
+
# fixed for _wait_for_signal below. Only remove it when the
|
|
494
|
+
# *Response* woke the loop instead; leaving a live source in
|
|
495
|
+
# that case holds a reference to this closure and fires it
|
|
496
|
+
# into a dead loop on some later request.
|
|
497
|
+
if not timed_out:
|
|
498
|
+
GLib.source_remove(timeout_source)
|
|
488
499
|
finally:
|
|
489
500
|
for subscription in subscriptions:
|
|
490
501
|
connection.signal_unsubscribe(subscription)
|
|
@@ -584,6 +595,11 @@ class RemoteDesktopSession:
|
|
|
584
595
|
session_handle: str | None = None,
|
|
585
596
|
busname: str = _BUS_NAME,
|
|
586
597
|
) -> None:
|
|
598
|
+
"""Hold a negotiated session's handle, connection and EIS fd.
|
|
599
|
+
|
|
600
|
+
Not for direct use -- built by :meth:`negotiate` once ``Start`` and
|
|
601
|
+
``ConnectToEIS`` have both already succeeded.
|
|
602
|
+
"""
|
|
587
603
|
self._connection = connection
|
|
588
604
|
self._eis_fd: int | None = eis_fd
|
|
589
605
|
self._session_handle = session_handle
|
|
@@ -687,10 +703,9 @@ class RemoteDesktopSession:
|
|
|
687
703
|
return
|
|
688
704
|
eis_fd = getattr(self, "_eis_fd", None)
|
|
689
705
|
if eis_fd is not None:
|
|
690
|
-
|
|
706
|
+
# an exception here is only printed to stderr anyway
|
|
707
|
+
with contextlib.suppress(OSError):
|
|
691
708
|
os.close(eis_fd)
|
|
692
|
-
except OSError:
|
|
693
|
-
pass # an exception here is only printed to stderr anyway
|
|
694
709
|
|
|
695
710
|
@classmethod
|
|
696
711
|
def negotiate(
|
|
@@ -819,7 +834,7 @@ class RemoteDesktopSession:
|
|
|
819
834
|
timeout=timeout,
|
|
820
835
|
)
|
|
821
836
|
except BaseException:
|
|
822
|
-
# BaseException, not Exception: `Start` blocks on a
|
|
837
|
+
# BaseException, not Exception: `Start` blocks on a user
|
|
823
838
|
# answering a consent dialog, so Ctrl-C during that wait is a
|
|
824
839
|
# routine way out of this function -- and it strands an
|
|
825
840
|
# approved session exactly as a decline does.
|
|
@@ -926,6 +941,53 @@ def _input_capture_version(
|
|
|
926
941
|
return int(version)
|
|
927
942
|
|
|
928
943
|
|
|
944
|
+
def _create_input_capture_session_v1(
|
|
945
|
+
connection: Any,
|
|
946
|
+
Gio: Any,
|
|
947
|
+
GLib: Any,
|
|
948
|
+
busname: str,
|
|
949
|
+
types: int,
|
|
950
|
+
timeout: float,
|
|
951
|
+
) -> str:
|
|
952
|
+
"""Create an InputCapture session on a portal that predates v2.
|
|
953
|
+
|
|
954
|
+
v1 has no ``Start`` at all -- that method, like ``CreateSession2``, was
|
|
955
|
+
added in version 2. Here ``CreateSession`` itself carries
|
|
956
|
+
``capabilities`` and raises the consent dialog, and the session is ready
|
|
957
|
+
for ``GetZones`` / ``SetPointerBarriers`` / ``Enable`` the moment its
|
|
958
|
+
``Response`` arrives. ``ConnectToEIS`` is a v1 original, so the rest of
|
|
959
|
+
this class works against such a portal unchanged.
|
|
960
|
+
|
|
961
|
+
Returns the session handle. ``persist_mode`` and ``restore_token`` have
|
|
962
|
+
no v1 equivalent -- both are documented as version 2 additions to
|
|
963
|
+
``Start``, which does not exist here -- so the caller must reject them
|
|
964
|
+
before getting this far rather than have them silently ignored.
|
|
965
|
+
"""
|
|
966
|
+
code, results = _request(
|
|
967
|
+
connection,
|
|
968
|
+
Gio,
|
|
969
|
+
GLib,
|
|
970
|
+
busname,
|
|
971
|
+
_INPUT_CAPTURE,
|
|
972
|
+
"CreateSession",
|
|
973
|
+
"(sa{sv})",
|
|
974
|
+
("",),
|
|
975
|
+
{
|
|
976
|
+
"session_handle_token": GLib.Variant("s", uuid.uuid4().hex),
|
|
977
|
+
"capabilities": GLib.Variant("u", int(types)),
|
|
978
|
+
},
|
|
979
|
+
timeout,
|
|
980
|
+
)
|
|
981
|
+
if code != 0:
|
|
982
|
+
raise PortalDeniedError(
|
|
983
|
+
"CreateSession", "the user declined the input-capture consent dialog"
|
|
984
|
+
)
|
|
985
|
+
session_handle = results.get("session_handle")
|
|
986
|
+
if not isinstance(session_handle, str):
|
|
987
|
+
raise PortalError("CreateSession returned no session_handle")
|
|
988
|
+
return session_handle
|
|
989
|
+
|
|
990
|
+
|
|
929
991
|
def _wait_for_signal(
|
|
930
992
|
connection: Any,
|
|
931
993
|
Gio: Any,
|
|
@@ -933,10 +995,10 @@ def _wait_for_signal(
|
|
|
933
995
|
busname: str,
|
|
934
996
|
interface: str,
|
|
935
997
|
signal: str,
|
|
936
|
-
|
|
998
|
+
session_handle: str,
|
|
937
999
|
timeout: float | None,
|
|
938
1000
|
) -> tuple[Any, ...]:
|
|
939
|
-
"""Block for one emission of ``signal``
|
|
1001
|
+
"""Block for one emission of ``signal`` for ``session_handle``, unpacked.
|
|
940
1002
|
|
|
941
1003
|
Unlike `_request`, nothing here *triggers* the signal: ``Activated`` and
|
|
942
1004
|
``Deactivated`` fire whenever the compositor decides a pointer barrier
|
|
@@ -944,7 +1006,23 @@ def _wait_for_signal(
|
|
|
944
1006
|
before this is even called (a long-enabled session, subscribed to
|
|
945
1007
|
late) or not for a long time. ``timeout=None`` waits indefinitely --
|
|
946
1008
|
the read a caller wants when there is nothing else useful to do but
|
|
947
|
-
wait for a
|
|
1009
|
+
wait for a user to move the pointer.
|
|
1010
|
+
|
|
1011
|
+
**These signals are emitted on the portal object, not on the session
|
|
1012
|
+
object.** Subscribing with the session handle as the D-Bus object path
|
|
1013
|
+
-- the obvious reading of "a signal for this session", and what this
|
|
1014
|
+
did until 2026-09-09 -- matches nothing, delivers nothing, and is
|
|
1015
|
+
indistinguishable from a compositor that never fires the signal at all:
|
|
1016
|
+
it cost this project a live investigation across two GNOME versions, a
|
|
1017
|
+
standalone C reproducer and an upstream Mutter bug report before an
|
|
1018
|
+
xdg-desktop-portal developer pointed out the mistake. The session is
|
|
1019
|
+
identified by the signal's own first argument instead (``o
|
|
1020
|
+
session_handle``, per the portal spec), so the subscription is on
|
|
1021
|
+
`_OBJECT_PATH` and the filtering happens here, on the payload. It is
|
|
1022
|
+
deliberately not done with ``signal_subscribe``'s ``arg0`` filter: the
|
|
1023
|
+
D-Bus specification restricts plain ``arg0=`` match rules to arguments
|
|
1024
|
+
of type STRING, and this one is an OBJECT_PATH, which is the same shape
|
|
1025
|
+
of silent non-delivery all over again.
|
|
948
1026
|
"""
|
|
949
1027
|
loop = GLib.MainLoop()
|
|
950
1028
|
result: dict[str, Any] = {}
|
|
@@ -961,7 +1039,22 @@ def _wait_for_signal(
|
|
|
961
1039
|
) -> None:
|
|
962
1040
|
if result: # a subscription that outlives its own wait can fire twice
|
|
963
1041
|
return
|
|
964
|
-
|
|
1042
|
+
args = params.unpack()
|
|
1043
|
+
if args[0] != session_handle:
|
|
1044
|
+
# Logged, not silently dropped: this filter can hide a signal
|
|
1045
|
+
# that *did* arrive just as effectively as the wrong-path
|
|
1046
|
+
# subscription above did, and the two are indistinguishable
|
|
1047
|
+
# from the outside -- both look exactly like a compositor that
|
|
1048
|
+
# never fired. One debug line is what tells them apart.
|
|
1049
|
+
logger.debug(
|
|
1050
|
+
"ignoring %s for session %s while waiting for %s",
|
|
1051
|
+
signal,
|
|
1052
|
+
args[0],
|
|
1053
|
+
session_handle,
|
|
1054
|
+
)
|
|
1055
|
+
return
|
|
1056
|
+
logger.debug("received %s for session %s", signal, session_handle)
|
|
1057
|
+
result["args"] = args
|
|
965
1058
|
loop.quit()
|
|
966
1059
|
|
|
967
1060
|
def on_timeout() -> bool:
|
|
@@ -974,12 +1067,20 @@ def _wait_for_signal(
|
|
|
974
1067
|
busname,
|
|
975
1068
|
interface,
|
|
976
1069
|
signal,
|
|
977
|
-
|
|
1070
|
+
_OBJECT_PATH,
|
|
978
1071
|
None,
|
|
979
1072
|
Gio.DBusSignalFlags.NONE,
|
|
980
1073
|
on_signal,
|
|
981
1074
|
None,
|
|
982
1075
|
)
|
|
1076
|
+
logger.debug(
|
|
1077
|
+
"waiting up to %s for %s.%s on %s for session %s",
|
|
1078
|
+
"forever" if timeout is None else f"{timeout:g}s",
|
|
1079
|
+
interface,
|
|
1080
|
+
signal,
|
|
1081
|
+
_OBJECT_PATH,
|
|
1082
|
+
session_handle,
|
|
1083
|
+
)
|
|
983
1084
|
try:
|
|
984
1085
|
if not result:
|
|
985
1086
|
timeout_source = None
|
|
@@ -988,7 +1089,16 @@ def _wait_for_signal(
|
|
|
988
1089
|
try:
|
|
989
1090
|
loop.run()
|
|
990
1091
|
finally:
|
|
991
|
-
|
|
1092
|
+
# A fired timeout source has already deregistered itself --
|
|
1093
|
+
# on_timeout() returns False, which is GLib's signal to
|
|
1094
|
+
# remove it -- so only remove it here when the *signal*
|
|
1095
|
+
# woke the loop instead. Removing it unconditionally trips
|
|
1096
|
+
# a real GLib warning ("Source ID N was not found when
|
|
1097
|
+
# attempting to remove it"), confirmed live: it fired on
|
|
1098
|
+
# every real timeout this session hit, never against
|
|
1099
|
+
# test_portal.py's fake GLib.source_remove, which just
|
|
1100
|
+
# clears pending_timeout with no complaint either way.
|
|
1101
|
+
if timeout_source is not None and not timed_out:
|
|
992
1102
|
GLib.source_remove(timeout_source)
|
|
993
1103
|
finally:
|
|
994
1104
|
connection.signal_unsubscribe(subscription)
|
|
@@ -1002,8 +1112,9 @@ def _wait_for_signal(
|
|
|
1002
1112
|
|
|
1003
1113
|
|
|
1004
1114
|
class Activation(NamedTuple):
|
|
1005
|
-
"""One ``Activated`` signal's payload
|
|
1006
|
-
|
|
1115
|
+
"""One ``Activated`` signal's payload.
|
|
1116
|
+
|
|
1117
|
+
See :meth:`InputCaptureSession.wait_for_activation`.
|
|
1007
1118
|
"""
|
|
1008
1119
|
|
|
1009
1120
|
activation_id: int
|
|
@@ -1054,10 +1165,10 @@ class InputCaptureSession:
|
|
|
1054
1165
|
|
|
1055
1166
|
**Never live-tested.** Every other class in this module that talks to a
|
|
1056
1167
|
real portal carries a hand-verification note in its own docstring; this
|
|
1057
|
-
one does not, because verifying it means a
|
|
1058
|
-
consent dialog *and* accepting that their pointer will be diverted
|
|
1059
|
-
from their own desktop for the length of the test -- not something
|
|
1060
|
-
trigger without asking first, unlike everything else here. Designed
|
|
1168
|
+
one does not, because verifying it means a developer clicking through
|
|
1169
|
+
the consent dialog *and* accepting that their pointer will be diverted
|
|
1170
|
+
away from their own desktop for the length of the test -- not something
|
|
1171
|
+
to trigger without asking first, unlike everything else here. Designed
|
|
1061
1172
|
against ``/usr/share/dbus-1/interfaces/org.freedesktop.portal.
|
|
1062
1173
|
InputCapture.xml`` (the shipped portal spec, not the header alone) and
|
|
1063
1174
|
unit-tested against a fake connection reproducing that spec's documented
|
|
@@ -1073,6 +1184,11 @@ class InputCaptureSession:
|
|
|
1073
1184
|
restore_token: str | None,
|
|
1074
1185
|
busname: str = _BUS_NAME,
|
|
1075
1186
|
) -> None:
|
|
1187
|
+
"""Hold a negotiated capture session's handle, connection and EIS fd.
|
|
1188
|
+
|
|
1189
|
+
Not for direct use -- built by :meth:`negotiate` once ``ConnectToEIS``
|
|
1190
|
+
has already succeeded.
|
|
1191
|
+
"""
|
|
1076
1192
|
self._connection = connection
|
|
1077
1193
|
self._session_handle: str | None = session_handle
|
|
1078
1194
|
self._eis_fd: int | None = eis_fd
|
|
@@ -1273,13 +1389,24 @@ class InputCaptureSession:
|
|
|
1273
1389
|
)
|
|
1274
1390
|
if code != 0:
|
|
1275
1391
|
raise PortalDeniedError("SetPointerBarriers")
|
|
1276
|
-
|
|
1392
|
+
failed = list(results.get("failed_barriers", []))
|
|
1393
|
+
# A partially-refused set is the quiet failure here: the call
|
|
1394
|
+
# succeeds, some barriers stand, and an edge the caller believes is
|
|
1395
|
+
# armed simply never triggers. Callers see only the returned list,
|
|
1396
|
+
# which they may or may not act on -- this says it either way.
|
|
1397
|
+
logger.debug(
|
|
1398
|
+
"SetPointerBarriers: %d requested %s, refused: %s",
|
|
1399
|
+
len(barriers),
|
|
1400
|
+
[b[0] for b in barriers],
|
|
1401
|
+
failed or "none",
|
|
1402
|
+
)
|
|
1403
|
+
return failed
|
|
1277
1404
|
|
|
1278
1405
|
def wait_for_activation(self, timeout: float | None = None) -> Activation:
|
|
1279
1406
|
"""Block until the compositor activates capture, or ``timeout``.
|
|
1280
1407
|
|
|
1281
1408
|
Only returns once a real barrier crossing has been reported --
|
|
1282
|
-
which, on hardware, means a
|
|
1409
|
+
which, on hardware, means a user moved a physical pointer across
|
|
1283
1410
|
one. There is no way to trigger this synthetically (see the class
|
|
1284
1411
|
docstring's third paragraph), so this call can legitimately hang
|
|
1285
1412
|
until someone does that, and `timeout=None` -- the default -- waits
|
|
@@ -1371,10 +1498,8 @@ class InputCaptureSession:
|
|
|
1371
1498
|
return
|
|
1372
1499
|
eis_fd = getattr(self, "_eis_fd", None)
|
|
1373
1500
|
if eis_fd is not None:
|
|
1374
|
-
|
|
1501
|
+
with contextlib.suppress(OSError):
|
|
1375
1502
|
os.close(eis_fd)
|
|
1376
|
-
except OSError:
|
|
1377
|
-
pass
|
|
1378
1503
|
|
|
1379
1504
|
@classmethod
|
|
1380
1505
|
def negotiate(
|
|
@@ -1392,11 +1517,18 @@ class InputCaptureSession:
|
|
|
1392
1517
|
Blocks until ``CreateSession2`` -> ``Start`` -> ``ConnectToEIS``
|
|
1393
1518
|
resolves, prompting the user for consent along the way unless
|
|
1394
1519
|
``restore_token`` lets the portal skip that. Raises
|
|
1395
|
-
:class:`
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1520
|
+
:class:`PortalDeniedError` if consent is declined, and
|
|
1521
|
+
:class:`PortalTimeoutError` if any one round trip exceeds ``timeout``
|
|
1522
|
+
seconds.
|
|
1523
|
+
|
|
1524
|
+
A portal older than v2 has neither ``CreateSession2`` nor ``Start``,
|
|
1525
|
+
and is negotiated through the deprecated v1 ``CreateSession``
|
|
1526
|
+
instead -- one call that carries ``capabilities`` and raises the
|
|
1527
|
+
consent dialog itself. Everything after negotiation is identical;
|
|
1528
|
+
``ConnectToEIS`` is a v1 original. The one thing v1 cannot do is
|
|
1529
|
+
persist: ``persist_mode`` and ``restore_token`` were added to
|
|
1530
|
+
``Start``, so passing either against such a portal raises
|
|
1531
|
+
:class:`PortalVersionError` rather than being quietly ignored.
|
|
1400
1532
|
|
|
1401
1533
|
Returns before anything is actually captured: :meth:`set_pointer_barriers`
|
|
1402
1534
|
and :meth:`enable` still have to be called, and even then nothing
|
|
@@ -1435,62 +1567,95 @@ class InputCaptureSession:
|
|
|
1435
1567
|
raise PortalError(f"cannot reach the session bus: {exc}") from exc
|
|
1436
1568
|
|
|
1437
1569
|
version = _input_capture_version(connection, Gio, GLib, busname, timeout)
|
|
1438
|
-
|
|
1570
|
+
# A version below 2 is not a portal to give up on -- it is a portal
|
|
1571
|
+
# that speaks the older half of the same interface. Only
|
|
1572
|
+
# `CreateSession2` and `Start` are version 2 additions; `GetZones`,
|
|
1573
|
+
# `SetPointerBarriers`, `Enable`, `Disable`, `Release` and, crucially,
|
|
1574
|
+
# `ConnectToEIS` are all v1 originals, so everything this class does
|
|
1575
|
+
# after negotiation works either way and only session creation forks.
|
|
1576
|
+
#
|
|
1577
|
+
# This is not hypothetical. xdg-desktop-portal-gnome 50 reports
|
|
1578
|
+
# version 0 -- it registers the full impl interface and never sets the
|
|
1579
|
+
# property -- and calling `CreateSession2` against it fails with
|
|
1580
|
+
# `UnknownMethod`, while v1 `CreateSession` is dispatched normally.
|
|
1581
|
+
# Note that its introspection XML advertises `CreateSession2` anyway:
|
|
1582
|
+
# that XML is static, so it says nothing about what the frontend will
|
|
1583
|
+
# actually dispatch. The `version` property is the only usable signal,
|
|
1584
|
+
# which is why it is read first and believed.
|
|
1585
|
+
legacy = version < _MIN_INPUT_CAPTURE_VERSION
|
|
1586
|
+
|
|
1587
|
+
if legacy and (persist_mode != PersistMode.NONE or restore_token is not None):
|
|
1439
1588
|
raise PortalVersionError(
|
|
1440
|
-
f"InputCapture version {version}
|
|
1441
|
-
f"
|
|
1589
|
+
f"InputCapture version {version} has no Start method, and "
|
|
1590
|
+
f"persist_mode/restore_token are version "
|
|
1591
|
+
f"{_MIN_INPUT_CAPTURE_VERSION} additions to it -- this portal "
|
|
1592
|
+
f"cannot persist a session. Retry with persist_mode=NONE and "
|
|
1593
|
+
f"no restore_token to negotiate a one-shot session."
|
|
1442
1594
|
)
|
|
1443
1595
|
|
|
1444
1596
|
if capabilities == DeviceType.ALL_DEVICES:
|
|
1445
1597
|
types = _ALL_DEVICE_TYPES
|
|
1446
1598
|
else:
|
|
1447
1599
|
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
1600
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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(
|
|
1601
|
+
if legacy:
|
|
1602
|
+
session_handle = _create_input_capture_session_v1(
|
|
1603
|
+
connection, Gio, GLib, busname, int(types), timeout
|
|
1604
|
+
)
|
|
1605
|
+
else:
|
|
1606
|
+
reply = _call_sync(
|
|
1478
1607
|
connection,
|
|
1479
1608
|
Gio,
|
|
1480
1609
|
GLib,
|
|
1481
1610
|
busname,
|
|
1611
|
+
_OBJECT_PATH,
|
|
1482
1612
|
_INPUT_CAPTURE,
|
|
1483
|
-
"
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1613
|
+
"CreateSession2",
|
|
1614
|
+
GLib.Variant(
|
|
1615
|
+
"(a{sv})",
|
|
1616
|
+
({"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},),
|
|
1617
|
+
),
|
|
1618
|
+
None,
|
|
1619
|
+
int(timeout * 1000),
|
|
1488
1620
|
)
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1621
|
+
(results,) = reply.unpack()
|
|
1622
|
+
session_handle = results.get("session_handle")
|
|
1623
|
+
if not isinstance(session_handle, str):
|
|
1624
|
+
raise PortalError("CreateSession2 returned no session_handle")
|
|
1625
|
+
|
|
1626
|
+
# Past this point a session exists inside xdg-desktop-portal, and
|
|
1627
|
+
# nothing else can close it -- see RemoteDesktopSession.negotiate's
|
|
1628
|
+
# identical reasoning, which this mirrors line for line.
|
|
1629
|
+
try:
|
|
1630
|
+
# v1 asked for consent and capabilities in CreateSession itself,
|
|
1631
|
+
# and has no Start to call; it can therefore never issue a
|
|
1632
|
+
# restore token either.
|
|
1633
|
+
new_restore_token = None
|
|
1634
|
+
if not legacy:
|
|
1635
|
+
options: dict[str, Any] = {
|
|
1636
|
+
"capabilities": GLib.Variant("u", int(types))
|
|
1637
|
+
}
|
|
1638
|
+
if persist_mode != PersistMode.NONE:
|
|
1639
|
+
options["persist_mode"] = GLib.Variant("u", int(persist_mode))
|
|
1640
|
+
if restore_token is not None:
|
|
1641
|
+
options["restore_token"] = GLib.Variant("s", restore_token)
|
|
1642
|
+
code, start_results = _request(
|
|
1643
|
+
connection,
|
|
1644
|
+
Gio,
|
|
1645
|
+
GLib,
|
|
1646
|
+
busname,
|
|
1647
|
+
_INPUT_CAPTURE,
|
|
1648
|
+
"Start",
|
|
1649
|
+
"(osa{sv})",
|
|
1650
|
+
(session_handle, ""),
|
|
1651
|
+
options,
|
|
1652
|
+
timeout,
|
|
1492
1653
|
)
|
|
1493
|
-
|
|
1654
|
+
if code != 0:
|
|
1655
|
+
raise PortalDeniedError(
|
|
1656
|
+
"Start", "the user declined the input-capture consent dialog"
|
|
1657
|
+
)
|
|
1658
|
+
new_restore_token = start_results.get("restore_token")
|
|
1494
1659
|
|
|
1495
1660
|
eis_fd = _call_for_fd(
|
|
1496
1661
|
connection,
|
|
@@ -1503,7 +1668,7 @@ class InputCaptureSession:
|
|
|
1503
1668
|
timeout,
|
|
1504
1669
|
)
|
|
1505
1670
|
except BaseException:
|
|
1506
|
-
# BaseException, not Exception: Start blocks on a
|
|
1671
|
+
# BaseException, not Exception: Start blocks on a user
|
|
1507
1672
|
# answering a consent dialog, so Ctrl-C during that wait is a
|
|
1508
1673
|
# routine way out of this function -- and it strands an
|
|
1509
1674
|
# approved session exactly as a decline does.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
|
|
5
5
|
Author: Dennis K. Paulsen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -156,7 +156,7 @@ and why, is in
|
|
|
156
156
|
|
|
157
157
|
## Status
|
|
158
158
|
|
|
159
|
-
Beta (`0.5.
|
|
159
|
+
Beta (`0.5.2`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
160
160
|
since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
|
|
161
161
|
|
|
162
162
|
The injection path is exercised end to end against the real libraries by the
|
|
@@ -187,11 +187,11 @@ Exactly what was run, when, and against which versions:
|
|
|
187
187
|
portal paths are the part likeliest to come up short off Linux, since
|
|
188
188
|
they need an xdg-desktop-portal RemoteDesktop backend to talk to.
|
|
189
189
|
- CPython 3.10 or newer (tested on 3.13)
|
|
190
|
-
- The native
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
- `libei.portal` only: PyGObject
|
|
194
|
-
|
|
190
|
+
- The native `libei`, `libeis` and `liboeffis` libraries, which `pip` cannot
|
|
191
|
+
supply. Which package provides them on your distribution — and what to do
|
|
192
|
+
when the name does not match — is in [docs/install.md](docs/install.md).
|
|
193
|
+
- `libei.portal` only: PyGObject, via the `portal` extra, plus whatever
|
|
194
|
+
GObject-introspection libraries your distribution needs for `Gio`, since
|
|
195
195
|
PyPI's PyGObject wheel supplies the Python side only. Not needed for
|
|
196
196
|
`libei.ei`, `libei.eis` or `libei.oeffis`.
|
|
197
197
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
@@ -224,22 +224,11 @@ Exactly what was run, when, and against which versions:
|
|
|
224
224
|
|
|
225
225
|
## Install
|
|
226
226
|
|
|
227
|
-
From [PyPI](https://pypi.org/project/python-libei/):
|
|
228
|
-
|
|
229
227
|
```sh
|
|
230
228
|
pip install python-libei
|
|
231
229
|
```
|
|
232
230
|
|
|
233
|
-
|
|
234
|
-
`pip show python-libei`, but `from libei import ei`.
|
|
235
|
-
|
|
236
|
-
Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
|
|
237
|
-
the native libraries directly, so there is no compiler, no headers and no
|
|
238
|
-
`libei-devel` involved at install time. What `pip` does *not* bring is the
|
|
239
|
-
native libraries themselves -- see [Requirements](#requirements) above; on
|
|
240
|
-
Fedora, `sudo dnf install libei libeis liboeffis`.
|
|
241
|
-
|
|
242
|
-
To track `main` instead, or to hack on it, install from a checkout:
|
|
231
|
+
From a checkout instead, to track `main` or to work on the package:
|
|
243
232
|
|
|
244
233
|
```sh
|
|
245
234
|
git clone https://github.com/ctrondlp/python-libei.git
|
|
@@ -247,15 +236,11 @@ cd python-libei
|
|
|
247
236
|
pip install . # or `pip install -e '.[dev]'` to develop
|
|
248
237
|
```
|
|
249
238
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if not ei.is_available():
|
|
257
|
-
... # fall back to another input backend
|
|
258
|
-
```
|
|
239
|
+
That is the half `pip` can do. The native `libei`/`libeis`/`liboeffis`
|
|
240
|
+
libraries it cannot supply, the `portal` extra, and how to check what actually
|
|
241
|
+
loaded are all in [docs/install.md](docs/install.md) — see
|
|
242
|
+
[Requirements](#requirements) above for the version floors. Importing is safe
|
|
243
|
+
without any of it: those libraries are loaded on first *use*, not at import.
|
|
259
244
|
|
|
260
245
|
## Concepts
|
|
261
246
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
libei/__init__.py,sha256=SwaEhWSSGzxTEOqlW6PsfGC24QWz2A2FUrktq4AztTM,1858
|
|
2
|
+
libei/_cobject.py,sha256=msrbviABSWjc5fKsXHSCg7nQ0Y4Etcq_Dkh0fblW-sY,12845
|
|
3
|
+
libei/ei.py,sha256=5LAvMDOYS0AdwEHpKKZ3Anky5yIcDvr926A7ydL_Vmk,48289
|
|
4
|
+
libei/eis.py,sha256=HepGyded_soT84GUu66iSz9IcGP6Bm_Y2750-YK9GDg,44912
|
|
5
|
+
libei/oeffis.py,sha256=MhUPKD2Sk2OzKikaztNoL5k1CresqjdY7EPWuCPCMIM,9477
|
|
6
|
+
libei/portal.py,sha256=dGOX5Rf5slbEKq-Ie0od4_ZgStd8UruwMbSnaOO2QI8,68682
|
|
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=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
|
|
10
|
+
libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
|
|
11
|
+
libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
|
|
12
|
+
libei/_capi/loader.py,sha256=k7fb_Nz0fg5QkuLJ_duKXj8bESSktLQ2ZyS5LNQ5v2I,4689
|
|
13
|
+
python_libei-0.5.2.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
|
|
14
|
+
python_libei-0.5.2.dist-info/METADATA,sha256=IIL9EKM_LBc2PoqLzGQKu5sZHQ9l4ivyH5W3uq61CW0,20086
|
|
15
|
+
python_libei-0.5.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
python_libei-0.5.2.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
|
|
17
|
+
python_libei-0.5.2.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
libei/__init__.py,sha256=ovsIL7-rEp8LMbiWdmlGSoU6r4lNS_stMLULlkFgwGg,1858
|
|
2
|
-
libei/_cobject.py,sha256=msrbviABSWjc5fKsXHSCg7nQ0Y4Etcq_Dkh0fblW-sY,12845
|
|
3
|
-
libei/ei.py,sha256=Da3ezXnFPXtejlcJFH8bcE5f4pxKZXXqb6ltCh2zAdE,46704
|
|
4
|
-
libei/eis.py,sha256=aYWOCN1NlRchcWd6NjIUi1eYMFWJbp0UHMcQFEJ0ii4,43642
|
|
5
|
-
libei/oeffis.py,sha256=UcnDwLErFxB0xuktNpIzAK01ylpl6ZrmaGnWiUOcFOo,9302
|
|
6
|
-
libei/portal.py,sha256=-OAg_g0Q6s0BNN2B6bzfyD2T5N_dCuHbSpqtcL8dCUk,60488
|
|
7
|
-
libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
libei/_capi/__init__.py,sha256=3VxixYYlr_ZcuiK0GwrCpbE3VvEv_O990mJzdIJ8i74,209
|
|
9
|
-
libei/_capi/libei.py,sha256=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
|
|
10
|
-
libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
|
|
11
|
-
libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
|
|
12
|
-
libei/_capi/loader.py,sha256=k7fb_Nz0fg5QkuLJ_duKXj8bESSktLQ2ZyS5LNQ5v2I,4689
|
|
13
|
-
python_libei-0.5.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
|
|
14
|
-
python_libei-0.5.0.dist-info/METADATA,sha256=YDry3cY8Id0Z9bwQh_27SZkR8U8Ntf-S2t7Elf2nojo,20475
|
|
15
|
-
python_libei-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
-
python_libei-0.5.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
|
|
17
|
-
python_libei-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|