python-libei 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
libei/__init__.py CHANGED
@@ -13,6 +13,9 @@ Use the submodules directly:
13
13
  drive a test harness for the ``ei`` module without a real compositor)
14
14
  - :mod:`libei.oeffis` -- negotiate an EI connection through the
15
15
  ``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal
16
+ - :mod:`libei.portal` -- negotiate that same portal directly over D-Bus
17
+ instead, for ``persist_mode``/``restore_token`` support liboeffis's C API
18
+ doesn't expose
16
19
 
17
20
  Scope, in short: this needs a compositor speaking EI/EIS -- there is no X11
18
21
  fallback. Pointer (relative and absolute), button, keyboard, scroll, touch
@@ -23,9 +26,9 @@ evdev keycodes -- key positions, not characters -- though ``text_utf8()``
23
26
  sends characters directly where libei 1.6 is available. See the README for
24
27
  the full breakdown, including which features need which libei version.
25
28
 
26
- Alpha: the API is not frozen.
29
+ Beta: the API is not frozen.
27
30
  """
28
31
 
29
- __version__ = "0.2.0"
32
+ __version__ = "0.4.0"
30
33
 
31
34
  __all__ = ["__version__"]
libei/portal.py ADDED
@@ -0,0 +1,885 @@
1
+ """Negotiate an EIS connection by driving ``org.freedesktop.portal.RemoteDesktop``
2
+ directly over D-Bus, rather than through :mod:`libei.oeffis`.
3
+
4
+ :mod:`libei.oeffis` wraps liboeffis, whose C API
5
+ (``oeffis_create_session()``) takes only a device-type bitmask -- it exposes
6
+ neither ``persist_mode`` nor the ``restore_token`` a caller needs to avoid
7
+ re-prompting the user on every run. Upstream's own documentation is explicit
8
+ about why: liboeffis is "intentionally kept simple, any more complex needs
9
+ should be handled by an application talking to DBus directly"
10
+ (https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html).
11
+ This module is that: the ``CreateSession`` -> ``SelectDevices`` -> ``Start``
12
+ -> ``ConnectToEIS`` sequence driven directly, with ``persist_mode`` and
13
+ ``restore_token`` exposed as real parameters.
14
+
15
+ with RemoteDesktopSession.negotiate(
16
+ devices=DeviceType.POINTER | DeviceType.KEYBOARD,
17
+ persist_mode=PersistMode.UNTIL_REVOKED,
18
+ restore_token=saved_token, # None on the first run
19
+ ) as session:
20
+ save_somewhere(session.restore_token) # for next time
21
+ sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
22
+ ... # inject input for as long as the session is needed
23
+
24
+ Three things worth knowing before building on this:
25
+
26
+ * **Blocking, not event-driven.** Unlike :class:`libei.oeffis.Oeffis`
27
+ (poll ``fd``, call ``dispatch()`` until it returns ``True``),
28
+ :meth:`RemoteDesktopSession.negotiate` runs its own nested
29
+ ``GLib.MainLoop`` per D-Bus round trip and returns only once the whole
30
+ sequence has resolved, or raises. liboeffis is itself event-driven, which
31
+ is why ``Oeffis`` is; a caller driving GDBus directly already has
32
+ ``GLib.MainLoop`` available to it, and there is no equivalent requirement
33
+ here to expose an async surface -- so this doesn't. Each round trip is
34
+ bounded by ``timeout`` (:class:`PortalTimeoutError` when it expires),
35
+ since a blocking call with no escape hatch is the one thing ``Oeffis``'s
36
+ pollable fd would otherwise buy you.
37
+ * **Close it when done.** The portal session outlives this object unless
38
+ ``Session.Close()`` is called, and the EIS fd is owned by whoever
39
+ received it. :meth:`RemoteDesktopSession.close` (and the context-manager
40
+ form above) does both; see that method for what it does and does not
41
+ clean up.
42
+ * **Least automatically verified path in this package**, same caveat
43
+ :mod:`libei.oeffis` carries: nothing in CI can click through a real
44
+ consent dialog, so ``tests/test_portal.py`` exercises the
45
+ request/response orchestration against a fake D-Bus connection only.
46
+
47
+ Verified by hand 2026-09-01 against a real GNOME Wayland session
48
+ (xdg-desktop-portal, ``RemoteDesktop`` v2), end to end: a first run
49
+ raised the consent dialog and was approved with "Remember" checked
50
+ (5.4s), and a second run replaying the ``restore_token`` was granted
51
+ with no dialog at all (0.2s) -- which is the whole point of
52
+ ``persist_mode``, and is also what proves the first run was a genuine
53
+ first-time authorisation rather than a pre-existing grant. Three devices
54
+ resumed on the returned fd: relative pointer, keyboard, then absolute
55
+ pointer -- in that order, which is exactly the device race ``ei``-side
56
+ callers have to handle. ``Session.Close()`` was exercised too. No input
57
+ was injected: emulation is ``libei.ei``'s job and is not what this
58
+ module does.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import enum
64
+ import logging
65
+ import os
66
+ import time
67
+ import uuid
68
+ from typing import Any
69
+
70
+ from .oeffis import DeviceType
71
+
72
+ logger = logging.getLogger("libei.portal")
73
+
74
+ __all__ = [
75
+ "DeviceType",
76
+ "PersistMode",
77
+ "PortalError",
78
+ "PortalVersionError",
79
+ "PortalDeniedError",
80
+ "PortalTimeoutError",
81
+ "RemoteDesktopSession",
82
+ "is_available",
83
+ ]
84
+
85
+ _BUS_NAME = "org.freedesktop.portal.Desktop"
86
+ _OBJECT_PATH = "/org/freedesktop/portal/desktop"
87
+ _REMOTE_DESKTOP = "org.freedesktop.portal.RemoteDesktop"
88
+ _REQUEST_INTERFACE = "org.freedesktop.portal.Request"
89
+ _SESSION_INTERFACE = "org.freedesktop.portal.Session"
90
+
91
+ _MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
92
+
93
+ _DEFAULT_TIMEOUT = 60.0
94
+ """Seconds to wait for one portal round trip. Generous, because a human has
95
+ to see and answer the consent dialog `Start` raises -- but bounded, because
96
+ the alternative is a caller wedged forever if the portal dies after
97
+ accepting the call and before sending its `Response`."""
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
+
116
+ _ALL_DEVICE_TYPES = DeviceType.KEYBOARD | DeviceType.POINTER | DeviceType.TOUCHSCREEN
117
+ """Every bit the RemoteDesktop `types` bitmask defines.
118
+
119
+ `DeviceType.ALL_DEVICES` is liboeffis's own sentinel and is literally 0,
120
+ which the portal reads as *no* device types rather than all of them -- a
121
+ session that negotiates fine and then never resumes a single device. The
122
+ sentinel is translated to this before it reaches `SelectDevices`."""
123
+
124
+
125
+ class PersistMode(enum.IntEnum):
126
+ """``SelectDevices``'s ``persist_mode`` option, per the RemoteDesktop XML."""
127
+
128
+ NONE = 0
129
+ WHILE_RUNNING = 1
130
+ UNTIL_REVOKED = 2
131
+
132
+
133
+ class PortalError(Exception):
134
+ """Base class for this module's failures."""
135
+
136
+
137
+ class PortalVersionError(PortalError):
138
+ """The compositor's RemoteDesktop portal is too old for ConnectToEIS."""
139
+
140
+
141
+ class PortalTimeoutError(PortalError):
142
+ """A portal request did not answer within the timeout.
143
+
144
+ Distinct from a decline: the portal accepted the call and then never
145
+ sent its ``Response`` signal. Most often the consent dialog is simply
146
+ still waiting for a human, so raise the timeout rather than treating
147
+ this as a failure if that is expected.
148
+ """
149
+
150
+ def __init__(self, step: str, timeout: float) -> None:
151
+ super().__init__(f"{step} did not answer within {timeout:g}s")
152
+ self.step = step
153
+ self.timeout = timeout
154
+
155
+
156
+ class PortalDeniedError(PortalError):
157
+ """``CreateSession``, ``SelectDevices`` or ``Start`` was not approved.
158
+
159
+ Covers both an explicit user decline and any other non-zero portal
160
+ response code -- the portal spec does not guarantee a code means
161
+ "the user said no" versus some other failure, so this does not either.
162
+ """
163
+
164
+ def __init__(self, step: str, message: str | None = None) -> None:
165
+ super().__init__(message or f"{step} was not approved")
166
+ self.step = step
167
+ self.message = message
168
+
169
+
170
+ def _gio() -> tuple[Any, Any] | None:
171
+ """Import Gio and GLib, or return None.
172
+
173
+ Deferred so importing this module never requires PyGObject -- the same
174
+ "zero hard dependencies, probed at runtime" rule the rest of this
175
+ package follows. See is_available().
176
+ """
177
+ try:
178
+ import gi
179
+
180
+ gi.require_version("Gio", "2.0")
181
+ from gi.repository import Gio, GLib
182
+ except Exception:
183
+ return None
184
+ return Gio, GLib
185
+
186
+
187
+ def is_available() -> bool:
188
+ """Whether PyGObject (Gio) can be imported on this system.
189
+
190
+ Does not check for a running session bus or a portal implementation --
191
+ only whether the Python side this module needs is installed. A missing
192
+ session bus or portal surfaces as a `PortalError` from `negotiate()`.
193
+ """
194
+ return _gio() is not None
195
+
196
+
197
+ def _glib_error(GLib: Any) -> Any:
198
+ """``GLib.Error``, or a tuple that catches nothing where it is absent.
199
+
200
+ Every GDBus failure -- no session bus, no portal implementation behind
201
+ the name, a method that returns a D-Bus error -- arrives as this one
202
+ exception type. It is looked up rather than imported so that a test
203
+ double standing in for ``GLib`` need not define it: `except ()` catches
204
+ nothing, which is the right behaviour when there is no real GLib whose
205
+ errors could be raised in the first place.
206
+ """
207
+ return getattr(GLib, "Error", ())
208
+
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
+
247
+ def _call_sync(
248
+ connection: Any,
249
+ Gio: Any,
250
+ GLib: Any,
251
+ busname: str,
252
+ object_path: str,
253
+ interface: str,
254
+ method: str,
255
+ parameters: Any,
256
+ reply_type: Any,
257
+ timeout_msec: int = -1,
258
+ ) -> Any:
259
+ """``call_sync``, with GDBus failures translated to `PortalError`.
260
+
261
+ Without this a `GLib.Error` propagates raw, so the no-session-bus and
262
+ no-portal-backend cases -- exactly the ones `is_available()` documents
263
+ as surfacing here, since it deliberately checks neither -- escape a
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.
272
+ """
273
+ try:
274
+ return connection.call_sync(
275
+ busname,
276
+ object_path,
277
+ interface,
278
+ method,
279
+ parameters,
280
+ reply_type,
281
+ Gio.DBusCallFlags.NONE,
282
+ timeout_msec,
283
+ None,
284
+ )
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
289
+ raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
290
+
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
+
323
+ def _returned_handle(reply: Any) -> str | None:
324
+ """The request object path a Request-returning call replied with.
325
+
326
+ Every such portal method answers ``(o)``, but this stays defensive and
327
+ returns ``None`` on anything else: the value is only ever used as a
328
+ *second* path to listen on alongside the one derived from our own
329
+ handle_token, so a reply shaped unexpectedly is a reason to fall back to
330
+ that derived path, never to fail the negotiation outright.
331
+ """
332
+ try:
333
+ unpacked = reply.unpack()
334
+ except Exception:
335
+ return None
336
+ if isinstance(unpacked, tuple) and len(unpacked) == 1:
337
+ handle = unpacked[0]
338
+ if isinstance(handle, str):
339
+ return handle
340
+ return None
341
+
342
+
343
+ def _request(
344
+ connection: Any,
345
+ Gio: Any,
346
+ GLib: Any,
347
+ busname: str,
348
+ interface: str,
349
+ method: str,
350
+ signature: str,
351
+ leading_args: tuple[Any, ...],
352
+ options: dict[str, Any],
353
+ timeout: float,
354
+ ) -> tuple[int, Any]:
355
+ """Call a Request-returning portal method, racelessly.
356
+
357
+ Subscribing to the ``Response`` signal only *after* the call that
358
+ returns its request handle is a real race, not a hypothetical one: a
359
+ fast, non-interactive response (no consent dialog involved, e.g.
360
+ ``SelectDevices``) can arrive and be delivered before the subscription
361
+ is registered, hanging forever on a signal that already came and went.
362
+ Reproduced live (intermittent hangs at both ``SelectDevices`` and
363
+ ``SelectSources`` in the code this was ported from). Fixed by choosing
364
+ the ``handle_token`` ourselves, computing the resulting request object
365
+ path up front, and subscribing to that exact path *before* making the
366
+ call at all -- the pattern xdg-desktop-portal's own documentation
367
+ describes.
368
+
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.
377
+ """
378
+ deadline = time.monotonic() + timeout
379
+ unique_name = connection.get_unique_name()
380
+ escaped_sender = unique_name[1:].replace(".", "_")
381
+ token = uuid.uuid4().hex
382
+ options = dict(options)
383
+ options["handle_token"] = GLib.Variant("s", token)
384
+ expected_path = f"/org/freedesktop/portal/desktop/request/{escaped_sender}/{token}"
385
+
386
+ loop = GLib.MainLoop()
387
+ result: dict[str, Any] = {}
388
+ subscriptions: list[Any] = []
389
+ # Set inside the timeout callback rather than inferred from an empty
390
+ # `result` afterwards: a Response carrying no results is legitimate
391
+ # (SelectDevices answers with an empty dict), so "did the loop end
392
+ # because it timed out" has to be recorded, not deduced.
393
+ timed_out = False
394
+
395
+ def on_response(
396
+ _conn: Any,
397
+ _sender: Any,
398
+ _path: Any,
399
+ _iface: Any,
400
+ _signal: Any,
401
+ params: Any,
402
+ *_a: Any,
403
+ ) -> None:
404
+ if result: # both subscriptions may fire; the first reply wins
405
+ return
406
+ result["code"], result["results"] = params.unpack()
407
+ loop.quit()
408
+
409
+ def on_timeout() -> bool:
410
+ nonlocal timed_out
411
+ timed_out = True
412
+ loop.quit()
413
+ return False # one-shot; GLib removes the source when this is False
414
+
415
+ def subscribe(path: str) -> None:
416
+ subscriptions.append(
417
+ connection.signal_subscribe(
418
+ busname,
419
+ _REQUEST_INTERFACE,
420
+ "Response",
421
+ path,
422
+ None,
423
+ Gio.DBusSignalFlags.NONE,
424
+ on_response,
425
+ None,
426
+ )
427
+ )
428
+
429
+ subscribe(expected_path)
430
+ try:
431
+ parameters = GLib.Variant(signature, (*leading_args, options))
432
+ reply = _call_sync(
433
+ connection,
434
+ Gio,
435
+ GLib,
436
+ busname,
437
+ _OBJECT_PATH,
438
+ interface,
439
+ method,
440
+ parameters,
441
+ None,
442
+ _msec_until(deadline),
443
+ )
444
+ # The spec says the handle the call returns matches the path derived
445
+ # from our own handle_token, but a portal is free to hand back
446
+ # something else -- and some do. Listening on both is strictly safer
447
+ # than trusting either alone: watching only the derived path means a
448
+ # Response delivered to the returned handle is never seen, and the
449
+ # wait below then runs out the full timeout for no reason.
450
+ handle = _returned_handle(reply)
451
+ if handle is not None and handle != expected_path:
452
+ subscribe(handle)
453
+ # `if not result` because a synchronous answer (a fast
454
+ # non-interactive Response, or a test double) can arrive during the
455
+ # call above, before run() is reached -- and quit() on a loop that
456
+ # is not running yet does not stop the later run(), so running it
457
+ # then would block with the reply already delivered.
458
+ if not result:
459
+ timeout_source = GLib.timeout_add(_msec_until(deadline), on_timeout)
460
+ try:
461
+ loop.run()
462
+ finally:
463
+ # Removing an already-fired one-shot source is harmless
464
+ # (GLib warns at most); leaking a live one holds a reference
465
+ # to this closure and fires it into a dead loop later.
466
+ GLib.source_remove(timeout_source)
467
+ finally:
468
+ for subscription in subscriptions:
469
+ connection.signal_unsubscribe(subscription)
470
+ if timed_out:
471
+ raise PortalTimeoutError(method, timeout)
472
+ return result["code"], result["results"]
473
+
474
+
475
+ def _call_for_fd(
476
+ connection: Any,
477
+ Gio: Any,
478
+ GLib: Any,
479
+ busname: str,
480
+ interface: str,
481
+ method: str,
482
+ session_handle: str,
483
+ timeout: float,
484
+ ) -> int:
485
+ """Call a method that returns a fd via a GUnixFDList index.
486
+
487
+ The fd that comes back is *owned* -- `g_unix_fd_list_get()` dups it --
488
+ so whoever receives it has to close it. See
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.
495
+ """
496
+ try:
497
+ reply, fd_list = connection.call_with_unix_fd_list_sync(
498
+ busname,
499
+ _OBJECT_PATH,
500
+ interface,
501
+ method,
502
+ GLib.Variant("(oa{sv})", (session_handle, {})),
503
+ GLib.VariantType.new("(h)"),
504
+ Gio.DBusCallFlags.NONE,
505
+ int(timeout * 1000),
506
+ None,
507
+ None,
508
+ )
509
+ except _glib_error(GLib) as exc:
510
+ if _is_reply_timeout(Gio, exc):
511
+ raise PortalTimeoutError(method, timeout) from exc
512
+ raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
513
+ (handle_index,) = reply.unpack()
514
+ return fd_list.get(handle_index)
515
+
516
+
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."""
521
+ reply = _call_sync(
522
+ connection,
523
+ Gio,
524
+ GLib,
525
+ busname,
526
+ _OBJECT_PATH,
527
+ "org.freedesktop.DBus.Properties",
528
+ "Get",
529
+ GLib.Variant("(ss)", (_REMOTE_DESKTOP, "version")),
530
+ None,
531
+ int(timeout * 1000),
532
+ )
533
+ (version,) = reply.unpack()
534
+ return int(version)
535
+
536
+
537
+ class RemoteDesktopSession:
538
+ """A negotiated ``org.freedesktop.portal.RemoteDesktop`` session.
539
+
540
+ Two things here need releasing, and neither happens on its own when
541
+ this object is dropped:
542
+
543
+ * **The portal session.** It lives in xdg-desktop-portal, not in this
544
+ process, and persists until ``Session.Close()`` is called or the D-Bus
545
+ connection that created it drops. That connection is *not* owned here
546
+ -- ``Gio.bus_get_sync()`` hands back GLib's shared session-bus
547
+ singleton, which outlives any one session -- so a long-running process
548
+ that negotiates repeatedly accumulates live portal sessions until it
549
+ exits. :meth:`close` is what ends one.
550
+ * **The EIS fd**, which arrives dup'd and owned. Reading :attr:`eis_fd`
551
+ hands that ownership on (typically straight to
552
+ :meth:`libei.ei.Sender.create_for_fd`, which closes it itself); if it
553
+ is never read, :meth:`close` closes it rather than leaking it.
554
+
555
+ Use it as a context manager, or call :meth:`close` when done.
556
+ """
557
+
558
+ def __init__(
559
+ self,
560
+ connection: Any,
561
+ eis_fd: int,
562
+ restore_token: str | None,
563
+ session_handle: str | None = None,
564
+ busname: str = _BUS_NAME,
565
+ ) -> None:
566
+ self._connection = connection
567
+ self._eis_fd: int | None = eis_fd
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
573
+ # Mirrors libei.oeffis.Oeffis's ownership rule: reading `eis_fd`
574
+ # hands the fd to the caller, so close() must not also close it once
575
+ # that has happened -- but nothing else will ever close it if the
576
+ # session dies before anyone reads it, so close() must in that case.
577
+ self._eis_fd_claimed = False
578
+ self._closed = False
579
+ self.restore_token = restore_token
580
+ """The token to pass as ``restore_token=`` on the next call to
581
+ avoid re-prompting, or ``None`` if the portal issued none -- either
582
+ because no ``persist_mode`` was requested, or the portal declined
583
+ to grant persistence.
584
+
585
+ Save whatever comes back on *every* run rather than only the first:
586
+ the portal is free to hand back a different token each time, and a
587
+ caller that keeps only the original would eventually present a stale
588
+ one. (Observed 2026-09-01 on GNOME: the same token comes back on
589
+ each restore. That is this portal's behaviour, not a guarantee --
590
+ the interface permits a new one.)
591
+
592
+ Nothing is written to disk here: a token is a standing grant of
593
+ input injection, so storing it is the caller's decision."""
594
+
595
+ @property
596
+ def eis_fd(self) -> int:
597
+ """The fd to pass to :meth:`libei.ei.Sender.create_for_fd`.
598
+
599
+ Reading this transfers ownership of the fd to the caller -- after
600
+ that, closing it is the caller's job (or, far more usually, the
601
+ `Sender`'s, which takes ownership and closes it itself).
602
+ """
603
+ if self._eis_fd is None:
604
+ raise PortalError("the session is closed; its EIS fd is gone")
605
+ self._eis_fd_claimed = True
606
+ return self._eis_fd
607
+
608
+ def close(self) -> None:
609
+ """End the portal session, and close the EIS fd if unclaimed.
610
+
611
+ Idempotent. ``Session.Close()`` failures are logged and swallowed:
612
+ the session may already be gone (the portal restarted, the user
613
+ revoked the grant), and there is nothing a caller could usefully do
614
+ about it during cleanup either way.
615
+
616
+ Note that this does *not* disturb an `ei.Sender` already built on
617
+ the fd -- closing the portal session is what tears the EIS
618
+ connection down, so do it when finished injecting, not before.
619
+ """
620
+ if self._closed:
621
+ return
622
+ self._closed = True
623
+ if self._eis_fd is not None and not self._eis_fd_claimed:
624
+ try:
625
+ os.close(self._eis_fd)
626
+ except OSError as exc:
627
+ # Nothing a caller could do about a failed close during
628
+ # cleanup, and raising here would mask whatever exception
629
+ # was already unwinding through a `with` block.
630
+ logger.debug("closing the EIS fd failed: %s", exc)
631
+ self._eis_fd = None
632
+ if self._session_handle is None or self._connection is None:
633
+ return
634
+ gio_modules = _gio()
635
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
636
+ return
637
+ Gio, GLib = gio_modules
638
+ try:
639
+ _close_session(
640
+ self._connection,
641
+ Gio,
642
+ GLib,
643
+ self._busname,
644
+ self._session_handle,
645
+ )
646
+ finally:
647
+ self._session_handle = None
648
+ self._connection = None
649
+
650
+ def __enter__(self) -> RemoteDesktopSession:
651
+ return self
652
+
653
+ def __exit__(self, *_exc: Any) -> None:
654
+ self.close()
655
+
656
+ def __del__(self) -> None:
657
+ # Deliberately only the fd, not the D-Bus half of close(): __del__
658
+ # can run during interpreter shutdown, where a synchronous D-Bus
659
+ # round trip may hang or fail in ways nothing can report. Closing an
660
+ # unclaimed fd is the part that is always safe and always necessary
661
+ # -- nothing else will ever close it. getattr() with defaults
662
+ # because __init__ can raise before these exist, and a bare
663
+ # attribute access would then raise AttributeError inside __del__,
664
+ # which Python only prints to stderr.
665
+ if getattr(self, "_eis_fd_claimed", True):
666
+ return
667
+ eis_fd = getattr(self, "_eis_fd", None)
668
+ if eis_fd is not None:
669
+ try:
670
+ os.close(eis_fd)
671
+ except OSError:
672
+ pass # an exception here is only printed to stderr anyway
673
+
674
+ @classmethod
675
+ def negotiate(
676
+ cls,
677
+ *,
678
+ connection: Any = None,
679
+ devices: DeviceType = DeviceType.ALL_DEVICES,
680
+ persist_mode: PersistMode = PersistMode.NONE,
681
+ restore_token: str | None = None,
682
+ busname: str = _BUS_NAME,
683
+ timeout: float = _DEFAULT_TIMEOUT,
684
+ ) -> RemoteDesktopSession:
685
+ """Negotiate a RemoteDesktop portal session and connect it to EIS.
686
+
687
+ Blocks until the whole ``CreateSession`` -> ``SelectDevices`` ->
688
+ ``Start`` -> ``ConnectToEIS`` sequence resolves, prompting the user
689
+ for consent along the way unless ``restore_token`` lets the portal
690
+ skip that. Raises :class:`PortalVersionError` if the compositor's
691
+ RemoteDesktop portal is too old for ``ConnectToEIS`` (needs v2+),
692
+ :class:`PortalDeniedError` if any step is declined, and
693
+ :class:`PortalTimeoutError` if any one round trip exceeds
694
+ ``timeout`` seconds (60 by default -- generous, since ``Start``
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.
700
+
701
+ ``devices`` selects what to ask for. ``DeviceType.ALL_DEVICES`` is
702
+ liboeffis's sentinel for "everything" and is literally ``0``, which
703
+ the portal would read as *nothing*; it is translated here to every
704
+ type the portal defines.
705
+
706
+ ``persist_mode`` and ``restore_token`` are how a caller avoids
707
+ being prompted on every launch: ask for persistence, read
708
+ :attr:`restore_token` afterwards, store it, and hand it back next
709
+ time. Passing ``restore_token`` *without* a ``persist_mode`` raises
710
+ :class:`ValueError`: the portal answers such a request with no
711
+ token at all, so a caller following the store-what-comes-back rule
712
+ would write ``None`` over the token it just spent -- silently
713
+ ending the persistence it plainly meant to keep.
714
+
715
+ ``connection`` can be injected (a `Gio.DBusConnection`, or a
716
+ test double matching its subset of methods this module calls) to
717
+ reuse an existing bus connection, or to test this against a fake
718
+ one without a real portal -- see ``tests/test_portal.py``. Left as
719
+ ``None``, this opens a new session-bus connection itself.
720
+
721
+ No ScreenCast source is ever requested here: an absolute-pointer
722
+ EIS device carries its own region, and asking for ScreenCast too
723
+ would make the user grant screen-recording permission for nothing
724
+ an EIS-only caller needs.
725
+ """
726
+ if restore_token is not None and persist_mode == PersistMode.NONE:
727
+ raise ValueError(
728
+ "restore_token was given with persist_mode=NONE: the portal "
729
+ "consumes a restore token on use and only issues a new one "
730
+ "when persistence is requested, so this would spend the "
731
+ "saved token and hand back None. Pass a persist_mode too."
732
+ )
733
+
734
+ gio_modules = _gio()
735
+ if gio_modules is None:
736
+ raise PortalError(
737
+ "PyGObject is not installed; libei.portal needs it to "
738
+ "negotiate a RemoteDesktop portal session "
739
+ "(pip install 'python-libei[portal]')"
740
+ )
741
+ Gio, GLib = gio_modules
742
+
743
+ if connection is None:
744
+ try:
745
+ connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
746
+ except _glib_error(GLib) as exc:
747
+ raise PortalError(f"cannot reach the session bus: {exc}") from exc
748
+
749
+ version = _remote_desktop_version(connection, Gio, GLib, busname, timeout)
750
+ if version < _MIN_REMOTE_DESKTOP_VERSION:
751
+ raise PortalVersionError(
752
+ f"RemoteDesktop version {version} is too old for "
753
+ f"ConnectToEIS (need {_MIN_REMOTE_DESKTOP_VERSION}+)"
754
+ )
755
+
756
+ # session_handle_token is a *different* token from the handle_token
757
+ # _request() injects itself: omitting it crashes xdg-desktop-portal
758
+ # 1.22.1 outright (SIGABRT, "assertion failed:
759
+ # (session->token != NULL)") -- not optional.
760
+ code, results = _request(
761
+ connection,
762
+ Gio,
763
+ GLib,
764
+ busname,
765
+ _REMOTE_DESKTOP,
766
+ "CreateSession",
767
+ "(a{sv})",
768
+ (),
769
+ {"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},
770
+ timeout,
771
+ )
772
+ if code != 0:
773
+ raise PortalDeniedError("CreateSession")
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.
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
+ """
828
+ # ALL_DEVICES is 0, which SelectDevices reads as "no device types"
829
+ # rather than "every device type" -- see _ALL_DEVICE_TYPES.
830
+ types = _ALL_DEVICE_TYPES if devices == DeviceType.ALL_DEVICES else devices
831
+ options: dict[str, Any] = {"types": GLib.Variant("u", int(types))}
832
+ if persist_mode != PersistMode.NONE:
833
+ options["persist_mode"] = GLib.Variant("u", int(persist_mode))
834
+ if restore_token is not None:
835
+ options["restore_token"] = GLib.Variant("s", restore_token)
836
+ code, _results = _request(
837
+ connection,
838
+ Gio,
839
+ GLib,
840
+ busname,
841
+ _REMOTE_DESKTOP,
842
+ "SelectDevices",
843
+ "(oa{sv})",
844
+ (session_handle,),
845
+ options,
846
+ timeout,
847
+ )
848
+ if code != 0:
849
+ raise PortalDeniedError("SelectDevices")
850
+
851
+ code, results = _request(
852
+ connection,
853
+ Gio,
854
+ GLib,
855
+ busname,
856
+ _REMOTE_DESKTOP,
857
+ "Start",
858
+ "(osa{sv})",
859
+ (session_handle, ""),
860
+ {},
861
+ timeout,
862
+ )
863
+ if code != 0:
864
+ raise PortalDeniedError(
865
+ "Start", "the user declined the remote-control consent dialog"
866
+ )
867
+ new_restore_token = results.get("restore_token")
868
+
869
+ eis_fd = _call_for_fd(
870
+ connection,
871
+ Gio,
872
+ GLib,
873
+ busname,
874
+ _REMOTE_DESKTOP,
875
+ "ConnectToEIS",
876
+ session_handle,
877
+ timeout,
878
+ )
879
+ return cls(
880
+ connection,
881
+ eis_fd,
882
+ new_restore_token,
883
+ session_handle,
884
+ busname,
885
+ )
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.2.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
7
7
  Project-URL: homepage, https://github.com/ctrondlp/python-libei
8
8
  Project-URL: repository, https://github.com/ctrondlp/python-libei
9
9
  Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
10
+ Project-URL: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
10
11
  Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
11
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
12
13
  Classifier: Intended Audience :: Developers
13
14
  Classifier: Operating System :: POSIX :: Linux
14
15
  Classifier: Programming Language :: Python :: 3
@@ -25,6 +26,8 @@ Classifier: Typing :: Typed
25
26
  Requires-Python: >=3.10
26
27
  Description-Content-Type: text/markdown
27
28
  License-File: LICENSE
29
+ Provides-Extra: portal
30
+ Requires-Dist: PyGObject>=3.42; extra == "portal"
28
31
  Provides-Extra: dev
29
32
  Requires-Dist: pytest>=7; extra == "dev"
30
33
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -133,7 +136,7 @@ pointer.
133
136
 
134
137
  ## Status
135
138
 
136
- Alpha (`0.2.0`), published on [PyPI](https://pypi.org/project/python-libei/)
139
+ Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
137
140
  since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
138
141
  that qualifier covers, concretely:
139
142
 
@@ -144,9 +147,21 @@ that qualifier covers, concretely:
144
147
  - Text input, touch cancellation, ping/pong, keymap transfer, region mapping
145
148
  ids and `peek_event_type()` are each round-tripped through a real libeis
146
149
  server in `tests/test_integration_extras.py`.
147
- - The portal path (`libei.oeffis`) has only ever been verified by hand, since
148
- it needs an interactive consent dialog that nothing here can drive. See
149
- [Troubleshooting](#troubleshooting).
150
+ - Both portal paths (`libei.oeffis` and `libei.portal`) can only ever be
151
+ verified by hand, since they need an interactive consent dialog that
152
+ nothing here can drive automatically — `tests/test_portal.py` covers
153
+ `libei.portal`'s orchestration (raceless subscribe-before-call, the
154
+ `session_handle_token` crash workaround, persist_mode/restore_token, and
155
+ closing the portal session on a negotiation that fails part-way)
156
+ against a fake D-Bus connection only. `libei.oeffis` was verified by hand
157
+ on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
158
+ `libei.portal` on 2026-09-01 against a real GNOME Wayland session
159
+ (`RemoteDesktop` v2): a first run raised the consent dialog and was
160
+ approved (5.4s), a second replaying the `restore_token` was granted with
161
+ no dialog at all (0.2s), three devices resumed on the returned fd
162
+ (relative pointer, keyboard, absolute pointer — in that order, the device
163
+ race `ei`-side callers must handle), and `Session.Close()` was exercised.
164
+ No input was injected — emulation is `libei.ei`'s job.
150
165
  - Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
151
166
  locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
152
167
  gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
@@ -158,7 +173,7 @@ that qualifier covers, concretely:
158
173
  | Instead of this | Why you might |
159
174
  | --- | --- |
160
175
  | [snegg](https://gitlab.freedesktop.org/whot/snegg) | The reference bindings, by libei's own author — closer to upstream, and first to get new API. Self-described as for "rapid prototyping" with an explicitly unstable API, and `import snegg.ei` fails outright where libei isn't installed. [`docs/vs-snegg.md`](docs/vs-snegg.md) covers the differences in detail. |
161
- | The portal's D-Bus API directly (`org.freedesktop.portal.RemoteDesktop`, via Gio or dbus-python) | No native library and no bindings at all — `NotifyPointerMotion`, `NotifyKeyboardKeycode` and friends are plain method calls. The catch is absolute motion: `NotifyPointerMotionAbsolute` needs a PipeWire stream id, which only exists after a second, separate ScreenCast consent dialog. libei has no such requirement. |
176
+ | The RemoteDesktop portal's own D-Bus API directly (`NotifyPointerMotion`, `NotifyKeyboardKeycode`, …), bypassing libei/EI entirely | `libei.portal` already gets you the D-Bus session and its `persist_mode`/`restore_token` handling — reach past libei entirely only if you don't want the EI protocol at all. The catch if you do: `NotifyPointerMotionAbsolute` needs a PipeWire stream id, which only exists after a second, separate ScreenCast consent dialog. libei has no such requirement. |
162
177
  | `ydotool` and other `/dev/uinput` tools | Kernel-level, so they work under any compositor and need no portal session — at the cost of a privileged daemon, and of sidestepping the consent model that EI exists to enforce. |
163
178
 
164
179
  ## Requirements
@@ -166,6 +181,10 @@ that qualifier covers, concretely:
166
181
  - Linux with a Wayland compositor (GNOME, KDE, Sway, …)
167
182
  - CPython 3.10 or newer (tested on 3.13)
168
183
  - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
184
+ - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
185
+ whatever GObject-introspection libraries your distro needs for `Gio` --
186
+ PyPI's PyGObject wheel supplies the Python side only. Not needed for
187
+ `libei.ei`, `libei.eis` or `libei.oeffis`.
169
188
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
170
189
  sending pointer, button, keyboard, scroll and touch input all use symbols
171
190
  that have existed with a stable signature since 1.0.0, and upstream keeps
@@ -301,23 +320,59 @@ several devices — see the absolute-positioning notes under
301
320
  call and exposes no options dict, so the two things that make an approval
302
321
  persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
303
322
  comes back on `Start` — are unreachable through it. This is a limitation of
304
- the C library, not of these bindings; there is nothing here left to bind.
323
+ the C library, not of these bindings; upstream's own docs say as much:
324
+ liboeffis is "intentionally kept simple, any more complex needs should be
325
+ handled by an application talking to DBus directly"
326
+ ([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
305
327
 
306
- What works is negotiating the portal yourself over D-Bus and handing the
307
- resulting fd to `Sender.create_for_fd()`, which does not care where the fd
308
- came from:
328
+ `libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
329
+ → `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
330
+ `pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
331
+ as real parameters:
309
332
 
310
- 1. `CreateSession` on `org.freedesktop.portal.RemoteDesktop`
311
- 2. `SelectDevices` with `persist_mode` (1 = while running, 2 = until
312
- revoked) and, on later runs, the saved `restore_token`
313
- 3. `Start` — the response carries a fresh `restore_token`, which you store
314
- 4. `ConnectToEIS` — the fd for `ei.Sender.create_for_fd()`
333
+ ```python
334
+ from libei import ei, portal
335
+
336
+ with portal.RemoteDesktopSession.negotiate(
337
+ devices=portal.DeviceType.POINTER,
338
+ persist_mode=portal.PersistMode.UNTIL_REVOKED,
339
+ restore_token=saved_token, # None on the first run
340
+ ) as session:
341
+ save_somewhere(session.restore_token) # a fresh token every time -- save it
342
+ sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
343
+ ... # inject input for as long as the session is needed
344
+ ```
315
345
 
316
346
  Save the token somewhere durable and pass it back next time; the portal then
317
347
  restores the session without prompting. Treat it as a credential — anyone
318
348
  holding it can reopen input injection on that desktop, so it belongs
319
349
  wherever you'd keep a password, and the decision to store it at all belongs
320
- to the application rather than to a library.
350
+ to the application rather than to this library, which never writes it
351
+ anywhere itself.
352
+
353
+ Save whatever comes back on **every** run, not just the first: the portal is
354
+ free to hand back a different token each time, and a caller that keeps only
355
+ the original would eventually present a stale one. (On GNOME the same token
356
+ comes back on each restore — that is one portal's behaviour, not a
357
+ guarantee.) Passing `restore_token` *without* a `persist_mode` raises
358
+ `ValueError`: the portal answers such a request with no token at all, so
359
+ storing what came back would write `None` over the token you just spent.
360
+
361
+ Three differences from `Oeffis` above worth knowing:
362
+
363
+ - **Blocking, not event-driven.** `negotiate()` runs its own nested
364
+ `GLib.MainLoop` per D-Bus round trip and returns only once connected, or
365
+ raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
366
+ - **Bounded.** Each round trip gets `timeout` seconds (60 by default —
367
+ generous, since `Start` waits on a human answering a dialog). Without it a
368
+ portal that dies after accepting the call would wedge the calling thread
369
+ forever, which is the one thing `Oeffis`'s pollable fd protects against.
370
+ - **Close it.** The portal session lives in xdg-desktop-portal and outlives
371
+ the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
372
+ back GLib's *shared* connection, so dropping the session tears nothing
373
+ down, and a long-running process that negotiates repeatedly accumulates
374
+ live sessions. The `with` block above handles it; otherwise call
375
+ `session.close()`.
321
376
 
322
377
  ## Sending input
323
378
 
@@ -646,6 +701,7 @@ the capability you bound (`seat.capabilities`).
646
701
  | `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
647
702
  | `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
648
703
  | `libei.oeffis` | Getting an EI fd from the desktop portal |
704
+ | `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
649
705
 
650
706
  Each module has `is_available()`, an `Error` exception, and an `EventType` /
651
707
  `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
@@ -670,6 +726,12 @@ them in this order -- each one only makes sense once the one below it does.
670
726
  | [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
671
727
  | [`ei.py`](src/libei/ei.py), [`eis.py`](src/libei/eis.py), [`oeffis.py`](src/libei/oeffis.py) | The public API: Python classes, enums and dataclasses over the raw calls |
672
728
 
729
+ [`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
730
+ is no C library behind it, so no `_capi` binding and no `CObject`. It talks
731
+ D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
732
+ the C libraries are loaded lazily) and only ever produces a plain fd, which
733
+ is where it hands off to `ei.Sender.create_for_fd()`.
734
+
673
735
  **Read `_cobject.py` first.** It is the smallest file with the most
674
736
  consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
675
737
  `_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
@@ -734,16 +796,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
734
796
  `src/libei/__init__.py` -- which have to agree with each other and with the
735
797
  tag. Nothing enforces that yet.
736
798
 
737
- A release is an annotated, `v`-prefixed tag plus a GitHub Release:
799
+ A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
800
+ PyPI is the only place a release is published, and no GitHub Release is cut:
738
801
 
739
802
  ```sh
740
803
  git tag -a v0.2.0 -m "0.2.0"
741
804
  git push origin v0.2.0
742
- gh release create v0.2.0 --generate-notes --prerelease
743
805
  ```
744
806
 
745
- `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
746
- "Latest release" slot.
807
+ While the API is unfrozen, the pre-release signal lives in the version
808
+ itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
809
+ python-libei` off it, and a `0.x` version already says the API can move.
747
810
 
748
811
  Publishing runs from CI on a `v*` tag using PyPI
749
812
  [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
@@ -1,16 +1,17 @@
1
- libei/__init__.py,sha256=EWlmUgk0QL_TNN53lDU1ypnOVJcEJAUpNwsn1TUkKxQ,1511
1
+ libei/__init__.py,sha256=TNtT-iro7nzQKoATg37_LGuxqeOai0nbD8Cmw3FvhDY,1675
2
2
  libei/_cobject.py,sha256=msrbviABSWjc5fKsXHSCg7nQ0Y4Etcq_Dkh0fblW-sY,12845
3
3
  libei/ei.py,sha256=Da3ezXnFPXtejlcJFH8bcE5f4pxKZXXqb6ltCh2zAdE,46704
4
4
  libei/eis.py,sha256=aYWOCN1NlRchcWd6NjIUi1eYMFWJbp0UHMcQFEJ0ii4,43642
5
5
  libei/oeffis.py,sha256=UcnDwLErFxB0xuktNpIzAK01ylpl6ZrmaGnWiUOcFOo,9302
6
+ libei/portal.py,sha256=K74g5yhnlo0E1arGvoyD24zRnkKk2Th90-xEeg9wO_M,35342
6
7
  libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
8
  libei/_capi/__init__.py,sha256=3VxixYYlr_ZcuiK0GwrCpbE3VvEv_O990mJzdIJ8i74,209
8
9
  libei/_capi/libei.py,sha256=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
9
10
  libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
10
11
  libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
11
12
  libei/_capi/loader.py,sha256=k7fb_Nz0fg5QkuLJ_duKXj8bESSktLQ2ZyS5LNQ5v2I,4689
12
- python_libei-0.2.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
13
- python_libei-0.2.0.dist-info/METADATA,sha256=tdUGfZwL_MtWeduTv8wRBmNhGo0jT04PnLRVbOR6eT0,36184
14
- python_libei-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
- python_libei-0.2.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
16
- python_libei-0.2.0.dist-info/RECORD,,
13
+ python_libei-0.4.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
+ python_libei-0.4.0.dist-info/METADATA,sha256=_Vi9zTSjWz3Eho4gqQFjpdZzosdKa8-zQveksRDfYbU,40102
15
+ python_libei-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ python_libei-0.4.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
+ python_libei-0.4.0.dist-info/RECORD,,