python-libei 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {python_libei-0.3.0/src/python_libei.egg-info → python_libei-0.4.0}/PKG-INFO +5 -4
- {python_libei-0.3.0 → python_libei-0.4.0}/README.md +3 -2
- {python_libei-0.3.0 → python_libei-0.4.0}/pyproject.toml +2 -2
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/__init__.py +2 -2
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/portal.py +197 -20
- {python_libei-0.3.0 → python_libei-0.4.0/src/python_libei.egg-info}/PKG-INFO +5 -4
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_portal.py +289 -2
- {python_libei-0.3.0 → python_libei-0.4.0}/LICENSE +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/setup.cfg +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/libei.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/libeis.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/loader.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_cobject.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/ei.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/eis.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/oeffis.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/py.typed +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/requires.txt +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_cobject.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_documentation_shape.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_documented_examples.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_ei_objects.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_eis_objects.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_integration_extras.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_loader.py +0 -0
- {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_oeffis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -9,7 +9,7 @@ Project-URL: repository, https://github.com/ctrondlp/python-libei
|
|
|
9
9
|
Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
|
|
10
10
|
Project-URL: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
|
|
12
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -136,7 +136,7 @@ pointer.
|
|
|
136
136
|
|
|
137
137
|
## Status
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
140
140
|
since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
|
|
141
141
|
that qualifier covers, concretely:
|
|
142
142
|
|
|
@@ -151,7 +151,8 @@ that qualifier covers, concretely:
|
|
|
151
151
|
verified by hand, since they need an interactive consent dialog that
|
|
152
152
|
nothing here can drive automatically — `tests/test_portal.py` covers
|
|
153
153
|
`libei.portal`'s orchestration (raceless subscribe-before-call, the
|
|
154
|
-
`session_handle_token` crash workaround, persist_mode/restore_token
|
|
154
|
+
`session_handle_token` crash workaround, persist_mode/restore_token, and
|
|
155
|
+
closing the portal session on a negotiation that fails part-way)
|
|
155
156
|
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
156
157
|
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
157
158
|
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
@@ -100,7 +100,7 @@ pointer.
|
|
|
100
100
|
|
|
101
101
|
## Status
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
104
104
|
since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
|
|
105
105
|
that qualifier covers, concretely:
|
|
106
106
|
|
|
@@ -115,7 +115,8 @@ that qualifier covers, concretely:
|
|
|
115
115
|
verified by hand, since they need an interactive consent dialog that
|
|
116
116
|
nothing here can drive automatically — `tests/test_portal.py` covers
|
|
117
117
|
`libei.portal`'s orchestration (raceless subscribe-before-call, the
|
|
118
|
-
`session_handle_token` crash workaround, persist_mode/restore_token
|
|
118
|
+
`session_handle_token` crash workaround, persist_mode/restore_token, and
|
|
119
|
+
closing the portal session on a negotiation that fails part-way)
|
|
119
120
|
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
120
121
|
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
121
122
|
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-libei"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -27,7 +27,7 @@ keywords = [
|
|
|
27
27
|
"ctypes",
|
|
28
28
|
]
|
|
29
29
|
classifiers = [
|
|
30
|
-
"Development Status ::
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
31
31
|
"Intended Audience :: Developers",
|
|
32
32
|
"Operating System :: POSIX :: Linux",
|
|
33
33
|
"Programming Language :: Python :: 3",
|
|
@@ -26,9 +26,9 @@ evdev keycodes -- key positions, not characters -- though ``text_utf8()``
|
|
|
26
26
|
sends characters directly where libei 1.6 is available. See the README for
|
|
27
27
|
the full breakdown, including which features need which libei version.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Beta: the API is not frozen.
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.4.0"
|
|
33
33
|
|
|
34
34
|
__all__ = ["__version__"]
|
|
@@ -63,6 +63,7 @@ from __future__ import annotations
|
|
|
63
63
|
import enum
|
|
64
64
|
import logging
|
|
65
65
|
import os
|
|
66
|
+
import time
|
|
66
67
|
import uuid
|
|
67
68
|
from typing import Any
|
|
68
69
|
|
|
@@ -95,6 +96,23 @@ to see and answer the consent dialog `Start` raises -- but bounded, because
|
|
|
95
96
|
the alternative is a caller wedged forever if the portal dies after
|
|
96
97
|
accepting the call and before sending its `Response`."""
|
|
97
98
|
|
|
99
|
+
_GIO_DEFAULT_TIMEOUT_MSEC = 25_000
|
|
100
|
+
"""What GDBus's ``-1`` reply timeout actually is.
|
|
101
|
+
|
|
102
|
+
``call_sync(..., -1, ...)`` does not mean "wait forever" -- that is
|
|
103
|
+
``G_MAXINT`` -- it means GIO's own default, which is 25 seconds. Only used
|
|
104
|
+
to report the right number when a call left on that default runs out of
|
|
105
|
+
time; see `_call_sync`."""
|
|
106
|
+
|
|
107
|
+
_G_IO_ERROR_DOMAIN = "g-io-error-quark"
|
|
108
|
+
"""The GLib error domain GDBus reports its own reply timeout in.
|
|
109
|
+
|
|
110
|
+
Not one of the ``org.freedesktop.DBus.Error.*`` codes, as one might expect:
|
|
111
|
+
a ``call_sync()`` whose reply never arrives raises ``G_IO_ERROR_TIMED_OUT``
|
|
112
|
+
in this domain with the message "Timeout was reached" (checked against a
|
|
113
|
+
service that accepts a call and then deliberately never answers). See
|
|
114
|
+
`_is_reply_timeout`."""
|
|
115
|
+
|
|
98
116
|
_ALL_DEVICE_TYPES = DeviceType.KEYBOARD | DeviceType.POINTER | DeviceType.TOUCHSCREEN
|
|
99
117
|
"""Every bit the RemoteDesktop `types` bitmask defines.
|
|
100
118
|
|
|
@@ -189,6 +207,43 @@ def _glib_error(GLib: Any) -> Any:
|
|
|
189
207
|
return getattr(GLib, "Error", ())
|
|
190
208
|
|
|
191
209
|
|
|
210
|
+
def _is_reply_timeout(Gio: Any, exc: Exception) -> bool:
|
|
211
|
+
"""Whether a GDBus failure is its own reply timeout expiring.
|
|
212
|
+
|
|
213
|
+
Worth telling apart from every other ``GLib.Error``, because it means
|
|
214
|
+
exactly what the nested loop's own timeout means -- nobody answered in
|
|
215
|
+
the time allowed -- and so should raise the `PortalTimeoutError` a
|
|
216
|
+
caller is documented to catch for that, not a generic `PortalError`.
|
|
217
|
+
|
|
218
|
+
Defensive throughout: a test double standing in for ``Gio`` need not
|
|
219
|
+
define ``IOErrorEnum``, and one standing in for ``GLib.Error`` need not
|
|
220
|
+
carry a domain or a code. An attribute that isn't there just means
|
|
221
|
+
"not a timeout", which is the safe answer -- the failure still reaches
|
|
222
|
+
the caller, only as the more general error.
|
|
223
|
+
"""
|
|
224
|
+
timed_out = getattr(getattr(Gio, "IOErrorEnum", None), "TIMED_OUT", None)
|
|
225
|
+
if timed_out is None:
|
|
226
|
+
return False
|
|
227
|
+
return (
|
|
228
|
+
getattr(exc, "domain", None) == _G_IO_ERROR_DOMAIN
|
|
229
|
+
and getattr(exc, "code", None) == timed_out
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _msec_until(deadline: float) -> int:
|
|
234
|
+
"""Milliseconds left before ``deadline``, never negative.
|
|
235
|
+
|
|
236
|
+
Both legs of a round trip -- the call that returns the request handle,
|
|
237
|
+
then the wait for its ``Response`` signal -- draw down the same
|
|
238
|
+
deadline, so ``timeout`` bounds the round trip rather than each half of
|
|
239
|
+
it separately, which would let a slow first leg quietly double the wait
|
|
240
|
+
a caller asked for. Zero is a legitimate answer: GLib fires a 0ms
|
|
241
|
+
timeout on the next main-loop iteration, which is the right thing for a
|
|
242
|
+
deadline that has already passed.
|
|
243
|
+
"""
|
|
244
|
+
return max(0, int((deadline - time.monotonic()) * 1000))
|
|
245
|
+
|
|
246
|
+
|
|
192
247
|
def _call_sync(
|
|
193
248
|
connection: Any,
|
|
194
249
|
Gio: Any,
|
|
@@ -199,6 +254,7 @@ def _call_sync(
|
|
|
199
254
|
method: str,
|
|
200
255
|
parameters: Any,
|
|
201
256
|
reply_type: Any,
|
|
257
|
+
timeout_msec: int = -1,
|
|
202
258
|
) -> Any:
|
|
203
259
|
"""``call_sync``, with GDBus failures translated to `PortalError`.
|
|
204
260
|
|
|
@@ -206,6 +262,13 @@ def _call_sync(
|
|
|
206
262
|
no-portal-backend cases -- exactly the ones `is_available()` documents
|
|
207
263
|
as surfacing here, since it deliberately checks neither -- escape a
|
|
208
264
|
caller's `except PortalError`.
|
|
265
|
+
|
|
266
|
+
``timeout_msec`` is GDBus's own reply timeout, and defaults to its
|
|
267
|
+
``-1`` -- GIO's 25 seconds (see `_GIO_DEFAULT_TIMEOUT_MSEC`), which is
|
|
268
|
+
what `RemoteDesktopSession.close` leaves it at, having no caller-facing
|
|
269
|
+
timeout to honour. Anything reached from `negotiate` passes the
|
|
270
|
+
caller's own ``timeout`` instead, so that the bound on a round trip is
|
|
271
|
+
the documented one rather than a number this module never chose.
|
|
209
272
|
"""
|
|
210
273
|
try:
|
|
211
274
|
return connection.call_sync(
|
|
@@ -216,13 +279,47 @@ def _call_sync(
|
|
|
216
279
|
parameters,
|
|
217
280
|
reply_type,
|
|
218
281
|
Gio.DBusCallFlags.NONE,
|
|
219
|
-
|
|
282
|
+
timeout_msec,
|
|
220
283
|
None,
|
|
221
284
|
)
|
|
222
285
|
except _glib_error(GLib) as exc:
|
|
286
|
+
if _is_reply_timeout(Gio, exc):
|
|
287
|
+
expired = timeout_msec if timeout_msec >= 0 else _GIO_DEFAULT_TIMEOUT_MSEC
|
|
288
|
+
raise PortalTimeoutError(method, expired / 1000) from exc
|
|
223
289
|
raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
|
|
224
290
|
|
|
225
291
|
|
|
292
|
+
def _close_session(
|
|
293
|
+
connection: Any,
|
|
294
|
+
Gio: Any,
|
|
295
|
+
GLib: Any,
|
|
296
|
+
busname: str,
|
|
297
|
+
session_handle: str,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Call ``Session.Close()``, logging rather than raising on failure.
|
|
300
|
+
|
|
301
|
+
Shared by `RemoteDesktopSession.close` and `negotiate`'s failure path,
|
|
302
|
+
which need the same thing of it: the session may already be gone (the
|
|
303
|
+
portal restarted, the user revoked the grant), and neither a caller
|
|
304
|
+
tidying up nor an exception already unwinding is helped by a second
|
|
305
|
+
failure thrown over the top of the first.
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
_call_sync(
|
|
309
|
+
connection,
|
|
310
|
+
Gio,
|
|
311
|
+
GLib,
|
|
312
|
+
busname,
|
|
313
|
+
session_handle,
|
|
314
|
+
_SESSION_INTERFACE,
|
|
315
|
+
"Close",
|
|
316
|
+
None,
|
|
317
|
+
None,
|
|
318
|
+
)
|
|
319
|
+
except PortalError as exc:
|
|
320
|
+
logger.debug("closing the portal session failed: %s", exc)
|
|
321
|
+
|
|
322
|
+
|
|
226
323
|
def _returned_handle(reply: Any) -> str | None:
|
|
227
324
|
"""The request object path a Request-returning call replied with.
|
|
228
325
|
|
|
@@ -269,11 +366,16 @@ def _request(
|
|
|
269
366
|
call at all -- the pattern xdg-desktop-portal's own documentation
|
|
270
367
|
describes.
|
|
271
368
|
|
|
272
|
-
Raises :class:`PortalTimeoutError` if
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
369
|
+
Raises :class:`PortalTimeoutError` if the whole round trip -- the call
|
|
370
|
+
that returns the request handle, then the ``Response`` that answers it
|
|
371
|
+
-- exceeds ``timeout``. The nested loop is otherwise unbounded, and a
|
|
372
|
+
portal that dies after accepting the call sends no ``Response`` and no
|
|
373
|
+
error, leaving the caller wedged with nothing to poll and no way out.
|
|
374
|
+
Both legs share one deadline (see `_msec_until`), since a caller asking
|
|
375
|
+
for 60 seconds means the answer arrives inside 60 seconds, not inside
|
|
376
|
+
however many 60-second waits the sequence happens to be built from.
|
|
276
377
|
"""
|
|
378
|
+
deadline = time.monotonic() + timeout
|
|
277
379
|
unique_name = connection.get_unique_name()
|
|
278
380
|
escaped_sender = unique_name[1:].replace(".", "_")
|
|
279
381
|
token = uuid.uuid4().hex
|
|
@@ -337,6 +439,7 @@ def _request(
|
|
|
337
439
|
method,
|
|
338
440
|
parameters,
|
|
339
441
|
None,
|
|
442
|
+
_msec_until(deadline),
|
|
340
443
|
)
|
|
341
444
|
# The spec says the handle the call returns matches the path derived
|
|
342
445
|
# from our own handle_token, but a portal is free to hand back
|
|
@@ -353,7 +456,7 @@ def _request(
|
|
|
353
456
|
# is not running yet does not stop the later run(), so running it
|
|
354
457
|
# then would block with the reply already delivered.
|
|
355
458
|
if not result:
|
|
356
|
-
timeout_source = GLib.timeout_add(
|
|
459
|
+
timeout_source = GLib.timeout_add(_msec_until(deadline), on_timeout)
|
|
357
460
|
try:
|
|
358
461
|
loop.run()
|
|
359
462
|
finally:
|
|
@@ -377,12 +480,18 @@ def _call_for_fd(
|
|
|
377
480
|
interface: str,
|
|
378
481
|
method: str,
|
|
379
482
|
session_handle: str,
|
|
483
|
+
timeout: float,
|
|
380
484
|
) -> int:
|
|
381
485
|
"""Call a method that returns a fd via a GUnixFDList index.
|
|
382
486
|
|
|
383
487
|
The fd that comes back is *owned* -- `g_unix_fd_list_get()` dups it --
|
|
384
488
|
so whoever receives it has to close it. See
|
|
385
489
|
:meth:`RemoteDesktopSession.close`.
|
|
490
|
+
|
|
491
|
+
``ConnectToEIS`` returns no `Request`, so there is no ``Response`` to
|
|
492
|
+
wait on and nothing here beyond the call itself -- but it is a call to
|
|
493
|
+
the same portal that just made the caller wait on a consent dialog, so
|
|
494
|
+
it is bounded by the same ``timeout`` rather than by GIO's default.
|
|
386
495
|
"""
|
|
387
496
|
try:
|
|
388
497
|
reply, fd_list = connection.call_with_unix_fd_list_sync(
|
|
@@ -393,17 +502,22 @@ def _call_for_fd(
|
|
|
393
502
|
GLib.Variant("(oa{sv})", (session_handle, {})),
|
|
394
503
|
GLib.VariantType.new("(h)"),
|
|
395
504
|
Gio.DBusCallFlags.NONE,
|
|
396
|
-
|
|
505
|
+
int(timeout * 1000),
|
|
397
506
|
None,
|
|
398
507
|
None,
|
|
399
508
|
)
|
|
400
509
|
except _glib_error(GLib) as exc:
|
|
510
|
+
if _is_reply_timeout(Gio, exc):
|
|
511
|
+
raise PortalTimeoutError(method, timeout) from exc
|
|
401
512
|
raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
|
|
402
513
|
(handle_index,) = reply.unpack()
|
|
403
514
|
return fd_list.get(handle_index)
|
|
404
515
|
|
|
405
516
|
|
|
406
|
-
def _remote_desktop_version(
|
|
517
|
+
def _remote_desktop_version(
|
|
518
|
+
connection: Any, Gio: Any, GLib: Any, busname: str, timeout: float
|
|
519
|
+
) -> int:
|
|
520
|
+
"""Read the RemoteDesktop portal's ``version`` property."""
|
|
407
521
|
reply = _call_sync(
|
|
408
522
|
connection,
|
|
409
523
|
Gio,
|
|
@@ -414,6 +528,7 @@ def _remote_desktop_version(connection: Any, Gio: Any, GLib: Any, busname: str)
|
|
|
414
528
|
"Get",
|
|
415
529
|
GLib.Variant("(ss)", (_REMOTE_DESKTOP, "version")),
|
|
416
530
|
None,
|
|
531
|
+
int(timeout * 1000),
|
|
417
532
|
)
|
|
418
533
|
(version,) = reply.unpack()
|
|
419
534
|
return int(version)
|
|
@@ -446,10 +561,15 @@ class RemoteDesktopSession:
|
|
|
446
561
|
eis_fd: int,
|
|
447
562
|
restore_token: str | None,
|
|
448
563
|
session_handle: str | None = None,
|
|
564
|
+
busname: str = _BUS_NAME,
|
|
449
565
|
) -> None:
|
|
450
566
|
self._connection = connection
|
|
451
567
|
self._eis_fd: int | None = eis_fd
|
|
452
568
|
self._session_handle = session_handle
|
|
569
|
+
# Whichever bus name negotiate() used, not the default: a session
|
|
570
|
+
# created on an alternate portal name has to be closed on that same
|
|
571
|
+
# name, or Session.Close() goes to a portal that never heard of it.
|
|
572
|
+
self._busname = busname
|
|
453
573
|
# Mirrors libei.oeffis.Oeffis's ownership rule: reading `eis_fd`
|
|
454
574
|
# hands the fd to the caller, so close() must not also close it once
|
|
455
575
|
# that has happened -- but nothing else will ever close it if the
|
|
@@ -516,19 +636,13 @@ class RemoteDesktopSession:
|
|
|
516
636
|
return
|
|
517
637
|
Gio, GLib = gio_modules
|
|
518
638
|
try:
|
|
519
|
-
|
|
639
|
+
_close_session(
|
|
520
640
|
self._connection,
|
|
521
641
|
Gio,
|
|
522
642
|
GLib,
|
|
523
|
-
|
|
643
|
+
self._busname,
|
|
524
644
|
self._session_handle,
|
|
525
|
-
_SESSION_INTERFACE,
|
|
526
|
-
"Close",
|
|
527
|
-
None,
|
|
528
|
-
None,
|
|
529
645
|
)
|
|
530
|
-
except PortalError as exc:
|
|
531
|
-
logger.debug("closing the portal session failed: %s", exc)
|
|
532
646
|
finally:
|
|
533
647
|
self._session_handle = None
|
|
534
648
|
self._connection = None
|
|
@@ -578,7 +692,11 @@ class RemoteDesktopSession:
|
|
|
578
692
|
:class:`PortalDeniedError` if any step is declined, and
|
|
579
693
|
:class:`PortalTimeoutError` if any one round trip exceeds
|
|
580
694
|
``timeout`` seconds (60 by default -- generous, since ``Start``
|
|
581
|
-
waits on a human answering a dialog).
|
|
695
|
+
waits on a human answering a dialog). Any of those raised after
|
|
696
|
+
``CreateSession`` has succeeded closes the session it created on
|
|
697
|
+
the way out; nothing else could, since no `RemoteDesktopSession`
|
|
698
|
+
exists yet to own it and the portal would keep it alive for the
|
|
699
|
+
life of the bus connection.
|
|
582
700
|
|
|
583
701
|
``devices`` selects what to ask for. ``DeviceType.ALL_DEVICES`` is
|
|
584
702
|
liboeffis's sentinel for "everything" and is literally ``0``, which
|
|
@@ -628,7 +746,7 @@ class RemoteDesktopSession:
|
|
|
628
746
|
except _glib_error(GLib) as exc:
|
|
629
747
|
raise PortalError(f"cannot reach the session bus: {exc}") from exc
|
|
630
748
|
|
|
631
|
-
version = _remote_desktop_version(connection, Gio, GLib, busname)
|
|
749
|
+
version = _remote_desktop_version(connection, Gio, GLib, busname, timeout)
|
|
632
750
|
if version < _MIN_REMOTE_DESKTOP_VERSION:
|
|
633
751
|
raise PortalVersionError(
|
|
634
752
|
f"RemoteDesktop version {version} is too old for "
|
|
@@ -653,8 +771,60 @@ class RemoteDesktopSession:
|
|
|
653
771
|
)
|
|
654
772
|
if code != 0:
|
|
655
773
|
raise PortalDeniedError("CreateSession")
|
|
656
|
-
session_handle = results
|
|
774
|
+
session_handle = results.get("session_handle")
|
|
775
|
+
if not isinstance(session_handle, str):
|
|
776
|
+
# Approved, but with no handle to address the rest of the
|
|
777
|
+
# sequence to. `results` is portal-supplied data like any other
|
|
778
|
+
# reply, so a malformed one fails the way the rest of this
|
|
779
|
+
# module's do -- rather than as the KeyError a direct index
|
|
780
|
+
# would raise straight past a caller's `except PortalError`.
|
|
781
|
+
raise PortalError("CreateSession returned no session_handle")
|
|
782
|
+
|
|
783
|
+
# Past this point a session exists inside xdg-desktop-portal, and
|
|
784
|
+
# nothing else can close it: no RemoteDesktopSession owns it yet,
|
|
785
|
+
# and the connection it was created on is GLib's shared session-bus
|
|
786
|
+
# singleton, which outlives this failure rather than taking the
|
|
787
|
+
# session down with it. So every exit from here closes it.
|
|
788
|
+
try:
|
|
789
|
+
return cls._connect_devices(
|
|
790
|
+
connection,
|
|
791
|
+
Gio,
|
|
792
|
+
GLib,
|
|
793
|
+
busname,
|
|
794
|
+
session_handle,
|
|
795
|
+
devices=devices,
|
|
796
|
+
persist_mode=persist_mode,
|
|
797
|
+
restore_token=restore_token,
|
|
798
|
+
timeout=timeout,
|
|
799
|
+
)
|
|
800
|
+
except BaseException:
|
|
801
|
+
# BaseException, not Exception: `Start` blocks on a human
|
|
802
|
+
# answering a consent dialog, so Ctrl-C during that wait is a
|
|
803
|
+
# routine way out of this function -- and it strands an
|
|
804
|
+
# approved session exactly as a decline does.
|
|
805
|
+
_close_session(connection, Gio, GLib, busname, session_handle)
|
|
806
|
+
raise
|
|
807
|
+
|
|
808
|
+
@classmethod
|
|
809
|
+
def _connect_devices(
|
|
810
|
+
cls,
|
|
811
|
+
connection: Any,
|
|
812
|
+
Gio: Any,
|
|
813
|
+
GLib: Any,
|
|
814
|
+
busname: str,
|
|
815
|
+
session_handle: str,
|
|
816
|
+
*,
|
|
817
|
+
devices: DeviceType,
|
|
818
|
+
persist_mode: PersistMode,
|
|
819
|
+
restore_token: str | None,
|
|
820
|
+
timeout: float,
|
|
821
|
+
) -> RemoteDesktopSession:
|
|
822
|
+
"""``SelectDevices`` -> ``Start`` -> ``ConnectToEIS``, given a session.
|
|
657
823
|
|
|
824
|
+
Split out of :meth:`negotiate` only so that the caller can wrap the
|
|
825
|
+
whole of it in one ``try`` -- every step here can fail, and every
|
|
826
|
+
one of those failures leaves the same session behind to be closed.
|
|
827
|
+
"""
|
|
658
828
|
# ALL_DEVICES is 0, which SelectDevices reads as "no device types"
|
|
659
829
|
# rather than "every device type" -- see _ALL_DEVICE_TYPES.
|
|
660
830
|
types = _ALL_DEVICE_TYPES if devices == DeviceType.ALL_DEVICES else devices
|
|
@@ -704,5 +874,12 @@ class RemoteDesktopSession:
|
|
|
704
874
|
_REMOTE_DESKTOP,
|
|
705
875
|
"ConnectToEIS",
|
|
706
876
|
session_handle,
|
|
877
|
+
timeout,
|
|
878
|
+
)
|
|
879
|
+
return cls(
|
|
880
|
+
connection,
|
|
881
|
+
eis_fd,
|
|
882
|
+
new_restore_token,
|
|
883
|
+
session_handle,
|
|
884
|
+
busname,
|
|
707
885
|
)
|
|
708
|
-
return cls(connection, eis_fd, new_restore_token, session_handle)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -9,7 +9,7 @@ Project-URL: repository, https://github.com/ctrondlp/python-libei
|
|
|
9
9
|
Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
|
|
10
10
|
Project-URL: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
|
|
12
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -136,7 +136,7 @@ pointer.
|
|
|
136
136
|
|
|
137
137
|
## Status
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
140
140
|
since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
|
|
141
141
|
that qualifier covers, concretely:
|
|
142
142
|
|
|
@@ -151,7 +151,8 @@ that qualifier covers, concretely:
|
|
|
151
151
|
verified by hand, since they need an interactive consent dialog that
|
|
152
152
|
nothing here can drive automatically — `tests/test_portal.py` covers
|
|
153
153
|
`libei.portal`'s orchestration (raceless subscribe-before-call, the
|
|
154
|
-
`session_handle_token` crash workaround, persist_mode/restore_token
|
|
154
|
+
`session_handle_token` crash workaround, persist_mode/restore_token, and
|
|
155
|
+
closing the portal session on a negotiation that fails part-way)
|
|
155
156
|
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
156
157
|
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
157
158
|
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import os
|
|
23
23
|
import sys
|
|
24
|
+
import time
|
|
24
25
|
import types
|
|
25
26
|
from collections.abc import Iterator
|
|
26
27
|
from typing import Any
|
|
@@ -54,7 +55,18 @@ class FakeUnixFDList:
|
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class FakeGLibError(Exception):
|
|
57
|
-
"""Stands in for `GLib.Error`, which every GDBus failure arrives as.
|
|
58
|
+
"""Stands in for `GLib.Error`, which every GDBus failure arrives as.
|
|
59
|
+
|
|
60
|
+
`domain` and `code` are what portal._is_reply_timeout reads to tell
|
|
61
|
+
GDBus's own reply timeout from every other D-Bus failure. They default
|
|
62
|
+
to values that match nothing, so a bare FakeGLibError still stands for
|
|
63
|
+
"some other failure" as it did before.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str = "", domain: str = "", code: int = 0) -> None:
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.domain = domain
|
|
69
|
+
self.code = code
|
|
58
70
|
|
|
59
71
|
|
|
60
72
|
_OPEN_PIPES: list[tuple[int, int]] = []
|
|
@@ -97,6 +109,9 @@ class FakeMainLoop:
|
|
|
97
109
|
"""
|
|
98
110
|
|
|
99
111
|
pending_timeout: Any = None
|
|
112
|
+
# What GLib.timeout_add was asked to wait, so a test can check the
|
|
113
|
+
# Response leg got only the time the call leg left of the deadline.
|
|
114
|
+
pending_timeout_msec: int | None = None
|
|
100
115
|
|
|
101
116
|
def __init__(self) -> None:
|
|
102
117
|
self._quit = False
|
|
@@ -141,6 +156,9 @@ class FakeConnection:
|
|
|
141
156
|
self.version = version
|
|
142
157
|
self.fd_responses = fd_responses or {"ConnectToEIS": _fresh_fd()}
|
|
143
158
|
self.calls: list[tuple[str, Any]] = []
|
|
159
|
+
# Kept separate from `calls`, whose two-tuple shape several tests
|
|
160
|
+
# compare against exactly: (method, bus name, object path, timeout).
|
|
161
|
+
self.call_targets: list[tuple[str, Any, Any, Any]] = []
|
|
144
162
|
self._unique_name = unique_name
|
|
145
163
|
self._subscriptions: dict[str, Any] = {}
|
|
146
164
|
self._pending_reply: tuple[str, int, dict[str, Any]] | None = None
|
|
@@ -168,6 +186,7 @@ class FakeConnection:
|
|
|
168
186
|
# `parameters` is None for a method that takes no arguments, which
|
|
169
187
|
# is exactly what real Gio expects and what Session.Close() sends.
|
|
170
188
|
self.calls.append((method, None if parameters is None else parameters.value))
|
|
189
|
+
self.call_targets.append((method, bus_name, object_path, timeout))
|
|
171
190
|
if parameters is None:
|
|
172
191
|
return FakeReply(())
|
|
173
192
|
if method == "Get":
|
|
@@ -242,6 +261,7 @@ class FakeConnection:
|
|
|
242
261
|
cancellable: Any,
|
|
243
262
|
) -> tuple[FakeReply, FakeUnixFDList]:
|
|
244
263
|
self.calls.append((method, parameters.value))
|
|
264
|
+
self.call_targets.append((method, bus_name, object_path, timeout))
|
|
245
265
|
return FakeReply((0,)), FakeUnixFDList(self.fd_responses[method])
|
|
246
266
|
|
|
247
267
|
|
|
@@ -254,6 +274,9 @@ def install_fake_gi(connection: FakeConnection | None = None) -> Any:
|
|
|
254
274
|
Gio.BusType = types.SimpleNamespace(SESSION=1) # type: ignore[attr-defined]
|
|
255
275
|
Gio.DBusCallFlags = types.SimpleNamespace(NONE=0) # type: ignore[attr-defined]
|
|
256
276
|
Gio.DBusSignalFlags = types.SimpleNamespace(NONE=0) # type: ignore[attr-defined]
|
|
277
|
+
# 24 is G_IO_ERROR_TIMED_OUT, the code a real call_sync() raises in the
|
|
278
|
+
# g-io-error-quark domain when its reply never comes.
|
|
279
|
+
Gio.IOErrorEnum = types.SimpleNamespace(TIMED_OUT=24) # type: ignore[attr-defined]
|
|
257
280
|
Gio.bus_get_sync = ( # type: ignore[attr-defined]
|
|
258
281
|
lambda *a, **kw: connection or FakeConnection()
|
|
259
282
|
)
|
|
@@ -268,8 +291,9 @@ def install_fake_gi(connection: FakeConnection | None = None) -> Any:
|
|
|
268
291
|
|
|
269
292
|
# timeout_add stashes the callback rather than scheduling it; only the
|
|
270
293
|
# timeout tests, which never quit() the loop, actually let it fire.
|
|
271
|
-
def timeout_add(
|
|
294
|
+
def timeout_add(ms: int, callback: Any) -> int:
|
|
272
295
|
FakeMainLoop.pending_timeout = callback
|
|
296
|
+
FakeMainLoop.pending_timeout_msec = ms
|
|
273
297
|
return 1
|
|
274
298
|
|
|
275
299
|
def source_remove(_id: int) -> None:
|
|
@@ -630,3 +654,266 @@ def test_a_failed_session_close_does_not_propagate() -> None:
|
|
|
630
654
|
with install_fake_gi(connection):
|
|
631
655
|
session = portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
632
656
|
session.close() # must not raise
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _closed_sessions(connection: FakeConnection) -> list[Any]:
|
|
660
|
+
"""Object paths ``Session.Close()`` was called on, in order."""
|
|
661
|
+
return [call[2] for call in connection.call_targets if call[0] == "Close"]
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class _SilentAfterCreateConnection(FakeConnection):
|
|
665
|
+
"""Answers CreateSession, then accepts Start and never responds.
|
|
666
|
+
|
|
667
|
+
Stands in for a portal that dies (or a compositor whose dialog never
|
|
668
|
+
returns) after a session already exists -- the case where a caller is
|
|
669
|
+
left holding nothing at all, since `negotiate` raises rather than
|
|
670
|
+
returning the object whose `close()` would clean up.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
def call_sync(
|
|
674
|
+
self,
|
|
675
|
+
bus_name: Any,
|
|
676
|
+
object_path: Any,
|
|
677
|
+
interface: Any,
|
|
678
|
+
method: str,
|
|
679
|
+
parameters: FakeVariant | None,
|
|
680
|
+
reply_type: Any,
|
|
681
|
+
flags: Any,
|
|
682
|
+
timeout: Any,
|
|
683
|
+
cancellable: Any,
|
|
684
|
+
) -> FakeReply:
|
|
685
|
+
if method == "Start":
|
|
686
|
+
self.calls.append((method, parameters.value if parameters else None))
|
|
687
|
+
self.call_targets.append((method, bus_name, object_path, timeout))
|
|
688
|
+
return FakeReply(()) # accepted, but no Response ever fires
|
|
689
|
+
return super().call_sync(
|
|
690
|
+
bus_name,
|
|
691
|
+
object_path,
|
|
692
|
+
interface,
|
|
693
|
+
method,
|
|
694
|
+
parameters,
|
|
695
|
+
reply_type,
|
|
696
|
+
flags,
|
|
697
|
+
timeout,
|
|
698
|
+
cancellable,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_a_declined_select_devices_closes_the_portal_session() -> None:
|
|
703
|
+
# CreateSession has already created a session inside xdg-desktop-portal
|
|
704
|
+
# by this point, and nothing else will ever close it: negotiate() raises
|
|
705
|
+
# instead of returning the object whose close() would, and the session
|
|
706
|
+
# outlives the failure on the shared session-bus connection.
|
|
707
|
+
connection = FakeConnection(
|
|
708
|
+
responses={
|
|
709
|
+
"CreateSession": (0, {"session_handle": "/session/1"}),
|
|
710
|
+
"SelectDevices": (1, {}),
|
|
711
|
+
}
|
|
712
|
+
)
|
|
713
|
+
with install_fake_gi(connection):
|
|
714
|
+
with pytest.raises(portal.PortalDeniedError):
|
|
715
|
+
portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
716
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def test_a_declined_start_closes_the_portal_session() -> None:
|
|
720
|
+
# The most likely failure of the lot: the user says no to the consent
|
|
721
|
+
# dialog. An approved-then-declined session left open is a grant the
|
|
722
|
+
# portal keeps listing for a process that gave up on it.
|
|
723
|
+
connection = FakeConnection(
|
|
724
|
+
responses={
|
|
725
|
+
"CreateSession": (0, {"session_handle": "/session/1"}),
|
|
726
|
+
"SelectDevices": (0, {}),
|
|
727
|
+
"Start": (1, {}),
|
|
728
|
+
}
|
|
729
|
+
)
|
|
730
|
+
with install_fake_gi(connection):
|
|
731
|
+
with pytest.raises(portal.PortalDeniedError):
|
|
732
|
+
portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
733
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def test_a_timed_out_request_closes_the_portal_session() -> None:
|
|
737
|
+
connection = _SilentAfterCreateConnection()
|
|
738
|
+
with install_fake_gi(connection):
|
|
739
|
+
with pytest.raises(portal.PortalTimeoutError) as excinfo:
|
|
740
|
+
portal.RemoteDesktopSession.negotiate(connection=connection, timeout=0.01)
|
|
741
|
+
assert excinfo.value.step == "Start"
|
|
742
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_a_failed_connect_to_eis_closes_the_portal_session() -> None:
|
|
746
|
+
# The last step, and the one with no Request of its own: a session that
|
|
747
|
+
# was fully approved and then failed to hand back an EIS fd is still a
|
|
748
|
+
# session, and still has to be closed.
|
|
749
|
+
class NoEisConnection(FakeConnection):
|
|
750
|
+
def call_with_unix_fd_list_sync(
|
|
751
|
+
self, *args: Any, **kwargs: Any
|
|
752
|
+
) -> tuple[FakeReply, FakeUnixFDList]:
|
|
753
|
+
raise FakeGLibError("org.freedesktop.DBus.Error.Failed")
|
|
754
|
+
|
|
755
|
+
connection = NoEisConnection()
|
|
756
|
+
with install_fake_gi(connection):
|
|
757
|
+
with pytest.raises(portal.PortalError, match="D-Bus"):
|
|
758
|
+
portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
759
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def test_an_interrupted_consent_dialog_closes_the_portal_session() -> None:
|
|
763
|
+
# Why the cleanup catches BaseException: Start blocks on a human, so
|
|
764
|
+
# Ctrl-C during that wait is a routine way out of negotiate() -- and it
|
|
765
|
+
# strands an approved session exactly as a decline does.
|
|
766
|
+
class InterruptedConnection(FakeConnection):
|
|
767
|
+
def call_sync(
|
|
768
|
+
self,
|
|
769
|
+
bus_name: Any,
|
|
770
|
+
object_path: Any,
|
|
771
|
+
interface: Any,
|
|
772
|
+
method: str,
|
|
773
|
+
parameters: FakeVariant | None,
|
|
774
|
+
reply_type: Any,
|
|
775
|
+
flags: Any,
|
|
776
|
+
timeout: Any,
|
|
777
|
+
cancellable: Any,
|
|
778
|
+
) -> FakeReply:
|
|
779
|
+
if method == "Start":
|
|
780
|
+
raise KeyboardInterrupt
|
|
781
|
+
return super().call_sync(
|
|
782
|
+
bus_name,
|
|
783
|
+
object_path,
|
|
784
|
+
interface,
|
|
785
|
+
method,
|
|
786
|
+
parameters,
|
|
787
|
+
reply_type,
|
|
788
|
+
flags,
|
|
789
|
+
timeout,
|
|
790
|
+
cancellable,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
connection = InterruptedConnection()
|
|
794
|
+
with install_fake_gi(connection):
|
|
795
|
+
with pytest.raises(KeyboardInterrupt):
|
|
796
|
+
portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
797
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def test_a_successful_negotiation_closes_nothing() -> None:
|
|
801
|
+
# The other half of the cleanup: a session that negotiated fine belongs
|
|
802
|
+
# to the caller until they close it.
|
|
803
|
+
connection = FakeConnection()
|
|
804
|
+
with install_fake_gi(connection):
|
|
805
|
+
session = portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
806
|
+
assert _closed_sessions(connection) == []
|
|
807
|
+
session.close()
|
|
808
|
+
assert _closed_sessions(connection) == ["/session/1"]
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def test_a_missing_session_handle_raises_portal_error() -> None:
|
|
812
|
+
# Approved, but with nothing to address the rest of the sequence to.
|
|
813
|
+
# A direct index would raise KeyError straight past `except PortalError`.
|
|
814
|
+
connection = FakeConnection(responses={"CreateSession": (0, {})})
|
|
815
|
+
with install_fake_gi(connection):
|
|
816
|
+
with pytest.raises(portal.PortalError, match="session_handle"):
|
|
817
|
+
portal.RemoteDesktopSession.negotiate(connection=connection)
|
|
818
|
+
# And nothing to close: no handle means no session to end.
|
|
819
|
+
assert _closed_sessions(connection) == []
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def test_the_session_is_closed_on_the_bus_name_it_was_negotiated_on() -> None:
|
|
823
|
+
# A session created on an alternate portal name has to be closed on
|
|
824
|
+
# that same name; sending Session.Close() to the default bus name
|
|
825
|
+
# reaches a portal that never heard of this session.
|
|
826
|
+
connection = FakeConnection()
|
|
827
|
+
with install_fake_gi(connection):
|
|
828
|
+
session = portal.RemoteDesktopSession.negotiate(
|
|
829
|
+
connection=connection, busname="org.example.Portal"
|
|
830
|
+
)
|
|
831
|
+
session.close()
|
|
832
|
+
closes = [call for call in connection.call_targets if call[0] == "Close"]
|
|
833
|
+
assert [call[1] for call in closes] == ["org.example.Portal"]
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def test_a_failed_negotiation_closes_on_the_bus_name_it_used() -> None:
|
|
837
|
+
connection = FakeConnection(
|
|
838
|
+
responses={
|
|
839
|
+
"CreateSession": (0, {"session_handle": "/session/1"}),
|
|
840
|
+
"SelectDevices": (1, {}),
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
with install_fake_gi(connection):
|
|
844
|
+
with pytest.raises(portal.PortalDeniedError):
|
|
845
|
+
portal.RemoteDesktopSession.negotiate(
|
|
846
|
+
connection=connection, busname="org.example.Portal"
|
|
847
|
+
)
|
|
848
|
+
closes = [call for call in connection.call_targets if call[0] == "Close"]
|
|
849
|
+
assert [call[1] for call in closes] == ["org.example.Portal"]
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def test_every_negotiation_call_is_bounded_by_the_callers_timeout() -> None:
|
|
853
|
+
# GDBus's -1 is not "no timeout" (that is G_MAXINT) but GIO's own 25s
|
|
854
|
+
# default -- a number this module never chose and a caller cannot see.
|
|
855
|
+
# Every leg carries the timeout the caller asked for instead, including
|
|
856
|
+
# ConnectToEIS, which returns no Request and so has no Response leg of
|
|
857
|
+
# its own for the nested loop to bound.
|
|
858
|
+
connection = FakeConnection()
|
|
859
|
+
with install_fake_gi(connection):
|
|
860
|
+
portal.RemoteDesktopSession.negotiate(connection=connection, timeout=12.0)
|
|
861
|
+
timeouts = {call[0]: call[3] for call in connection.call_targets}
|
|
862
|
+
assert timeouts["Get"] == 12_000
|
|
863
|
+
assert timeouts["ConnectToEIS"] == 12_000
|
|
864
|
+
# The Request-returning legs draw on a shared deadline, so they get at
|
|
865
|
+
# most the full timeout, never more, and never GIO's default.
|
|
866
|
+
for method in ("CreateSession", "SelectDevices", "Start"):
|
|
867
|
+
assert 0 < timeouts[method] <= 12_000
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def test_the_response_wait_gets_only_what_the_call_leg_left(
|
|
871
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
872
|
+
) -> None:
|
|
873
|
+
# One deadline covers the whole round trip. Bounding each leg by the
|
|
874
|
+
# full timeout separately would let a request that spent 4s getting its
|
|
875
|
+
# handle wait another 10s for the Response -- 14 seconds, asked for 10.
|
|
876
|
+
class Clock:
|
|
877
|
+
def __init__(self) -> None:
|
|
878
|
+
self.now = 100.0
|
|
879
|
+
|
|
880
|
+
def __call__(self) -> float:
|
|
881
|
+
return self.now
|
|
882
|
+
|
|
883
|
+
clock = Clock()
|
|
884
|
+
|
|
885
|
+
class SlowCallConnection(_SilentAfterCreateConnection):
|
|
886
|
+
"""Takes 4s to answer Start, then never sends a Response."""
|
|
887
|
+
|
|
888
|
+
def call_sync(self, *args: Any, **kwargs: Any) -> FakeReply:
|
|
889
|
+
reply = super().call_sync(*args, **kwargs)
|
|
890
|
+
if args[3] == "Start":
|
|
891
|
+
clock.now += 4.0
|
|
892
|
+
return reply
|
|
893
|
+
|
|
894
|
+
# portal.py reads the stdlib clock directly, so this is the one to
|
|
895
|
+
# replace -- and replacing it beats sleeping for the real 4 seconds.
|
|
896
|
+
monkeypatch.setattr(time, "monotonic", clock)
|
|
897
|
+
connection = SlowCallConnection()
|
|
898
|
+
with install_fake_gi(connection):
|
|
899
|
+
with pytest.raises(portal.PortalTimeoutError):
|
|
900
|
+
portal.RemoteDesktopSession.negotiate(connection=connection, timeout=10.0)
|
|
901
|
+
assert FakeMainLoop.pending_timeout_msec == 6_000
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def test_a_dbus_reply_timeout_raises_portal_timeout_error() -> None:
|
|
905
|
+
# GDBus reports its own reply timeout as G_IO_ERROR_TIMED_OUT in the
|
|
906
|
+
# g-io-error-quark domain -- not as an org.freedesktop.DBus.Error.*
|
|
907
|
+
# code -- and it means what the nested loop's timeout means: nobody
|
|
908
|
+
# answered in time. So it raises what a caller catches for that.
|
|
909
|
+
class TimingOutConnection(FakeConnection):
|
|
910
|
+
def call_sync(self, *args: Any, **kwargs: Any) -> FakeReply:
|
|
911
|
+
raise FakeGLibError(
|
|
912
|
+
"Timeout was reached", domain="g-io-error-quark", code=24
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
connection = TimingOutConnection()
|
|
916
|
+
with install_fake_gi(connection):
|
|
917
|
+
with pytest.raises(portal.PortalTimeoutError) as excinfo:
|
|
918
|
+
portal.RemoteDesktopSession.negotiate(connection=connection, timeout=30.0)
|
|
919
|
+
assert excinfo.value.step == "Get"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|