python-libei 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libei/__init__.py +4 -1
- libei/portal.py +708 -0
- {python_libei-0.1.0.dist-info → python_libei-0.3.0.dist-info}/METADATA +114 -37
- {python_libei-0.1.0.dist-info → python_libei-0.3.0.dist-info}/RECORD +7 -6
- {python_libei-0.1.0.dist-info → python_libei-0.3.0.dist-info}/WHEEL +0 -0
- {python_libei-0.1.0.dist-info → python_libei-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_libei-0.1.0.dist-info → python_libei-0.3.0.dist-info}/top_level.txt +0 -0
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
|
|
@@ -26,6 +29,6 @@ the full breakdown, including which features need which libei version.
|
|
|
26
29
|
Alpha: the API is not frozen.
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.3.0"
|
|
30
33
|
|
|
31
34
|
__all__ = ["__version__"]
|
libei/portal.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
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 uuid
|
|
67
|
+
from typing import Any
|
|
68
|
+
|
|
69
|
+
from .oeffis import DeviceType
|
|
70
|
+
|
|
71
|
+
logger = logging.getLogger("libei.portal")
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"DeviceType",
|
|
75
|
+
"PersistMode",
|
|
76
|
+
"PortalError",
|
|
77
|
+
"PortalVersionError",
|
|
78
|
+
"PortalDeniedError",
|
|
79
|
+
"PortalTimeoutError",
|
|
80
|
+
"RemoteDesktopSession",
|
|
81
|
+
"is_available",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
_BUS_NAME = "org.freedesktop.portal.Desktop"
|
|
85
|
+
_OBJECT_PATH = "/org/freedesktop/portal/desktop"
|
|
86
|
+
_REMOTE_DESKTOP = "org.freedesktop.portal.RemoteDesktop"
|
|
87
|
+
_REQUEST_INTERFACE = "org.freedesktop.portal.Request"
|
|
88
|
+
_SESSION_INTERFACE = "org.freedesktop.portal.Session"
|
|
89
|
+
|
|
90
|
+
_MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
|
|
91
|
+
|
|
92
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
93
|
+
"""Seconds to wait for one portal round trip. Generous, because a human has
|
|
94
|
+
to see and answer the consent dialog `Start` raises -- but bounded, because
|
|
95
|
+
the alternative is a caller wedged forever if the portal dies after
|
|
96
|
+
accepting the call and before sending its `Response`."""
|
|
97
|
+
|
|
98
|
+
_ALL_DEVICE_TYPES = DeviceType.KEYBOARD | DeviceType.POINTER | DeviceType.TOUCHSCREEN
|
|
99
|
+
"""Every bit the RemoteDesktop `types` bitmask defines.
|
|
100
|
+
|
|
101
|
+
`DeviceType.ALL_DEVICES` is liboeffis's own sentinel and is literally 0,
|
|
102
|
+
which the portal reads as *no* device types rather than all of them -- a
|
|
103
|
+
session that negotiates fine and then never resumes a single device. The
|
|
104
|
+
sentinel is translated to this before it reaches `SelectDevices`."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PersistMode(enum.IntEnum):
|
|
108
|
+
"""``SelectDevices``'s ``persist_mode`` option, per the RemoteDesktop XML."""
|
|
109
|
+
|
|
110
|
+
NONE = 0
|
|
111
|
+
WHILE_RUNNING = 1
|
|
112
|
+
UNTIL_REVOKED = 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PortalError(Exception):
|
|
116
|
+
"""Base class for this module's failures."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PortalVersionError(PortalError):
|
|
120
|
+
"""The compositor's RemoteDesktop portal is too old for ConnectToEIS."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PortalTimeoutError(PortalError):
|
|
124
|
+
"""A portal request did not answer within the timeout.
|
|
125
|
+
|
|
126
|
+
Distinct from a decline: the portal accepted the call and then never
|
|
127
|
+
sent its ``Response`` signal. Most often the consent dialog is simply
|
|
128
|
+
still waiting for a human, so raise the timeout rather than treating
|
|
129
|
+
this as a failure if that is expected.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, step: str, timeout: float) -> None:
|
|
133
|
+
super().__init__(f"{step} did not answer within {timeout:g}s")
|
|
134
|
+
self.step = step
|
|
135
|
+
self.timeout = timeout
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PortalDeniedError(PortalError):
|
|
139
|
+
"""``CreateSession``, ``SelectDevices`` or ``Start`` was not approved.
|
|
140
|
+
|
|
141
|
+
Covers both an explicit user decline and any other non-zero portal
|
|
142
|
+
response code -- the portal spec does not guarantee a code means
|
|
143
|
+
"the user said no" versus some other failure, so this does not either.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, step: str, message: str | None = None) -> None:
|
|
147
|
+
super().__init__(message or f"{step} was not approved")
|
|
148
|
+
self.step = step
|
|
149
|
+
self.message = message
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _gio() -> tuple[Any, Any] | None:
|
|
153
|
+
"""Import Gio and GLib, or return None.
|
|
154
|
+
|
|
155
|
+
Deferred so importing this module never requires PyGObject -- the same
|
|
156
|
+
"zero hard dependencies, probed at runtime" rule the rest of this
|
|
157
|
+
package follows. See is_available().
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
import gi
|
|
161
|
+
|
|
162
|
+
gi.require_version("Gio", "2.0")
|
|
163
|
+
from gi.repository import Gio, GLib
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
return Gio, GLib
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_available() -> bool:
|
|
170
|
+
"""Whether PyGObject (Gio) can be imported on this system.
|
|
171
|
+
|
|
172
|
+
Does not check for a running session bus or a portal implementation --
|
|
173
|
+
only whether the Python side this module needs is installed. A missing
|
|
174
|
+
session bus or portal surfaces as a `PortalError` from `negotiate()`.
|
|
175
|
+
"""
|
|
176
|
+
return _gio() is not None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _glib_error(GLib: Any) -> Any:
|
|
180
|
+
"""``GLib.Error``, or a tuple that catches nothing where it is absent.
|
|
181
|
+
|
|
182
|
+
Every GDBus failure -- no session bus, no portal implementation behind
|
|
183
|
+
the name, a method that returns a D-Bus error -- arrives as this one
|
|
184
|
+
exception type. It is looked up rather than imported so that a test
|
|
185
|
+
double standing in for ``GLib`` need not define it: `except ()` catches
|
|
186
|
+
nothing, which is the right behaviour when there is no real GLib whose
|
|
187
|
+
errors could be raised in the first place.
|
|
188
|
+
"""
|
|
189
|
+
return getattr(GLib, "Error", ())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _call_sync(
|
|
193
|
+
connection: Any,
|
|
194
|
+
Gio: Any,
|
|
195
|
+
GLib: Any,
|
|
196
|
+
busname: str,
|
|
197
|
+
object_path: str,
|
|
198
|
+
interface: str,
|
|
199
|
+
method: str,
|
|
200
|
+
parameters: Any,
|
|
201
|
+
reply_type: Any,
|
|
202
|
+
) -> Any:
|
|
203
|
+
"""``call_sync``, with GDBus failures translated to `PortalError`.
|
|
204
|
+
|
|
205
|
+
Without this a `GLib.Error` propagates raw, so the no-session-bus and
|
|
206
|
+
no-portal-backend cases -- exactly the ones `is_available()` documents
|
|
207
|
+
as surfacing here, since it deliberately checks neither -- escape a
|
|
208
|
+
caller's `except PortalError`.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
return connection.call_sync(
|
|
212
|
+
busname,
|
|
213
|
+
object_path,
|
|
214
|
+
interface,
|
|
215
|
+
method,
|
|
216
|
+
parameters,
|
|
217
|
+
reply_type,
|
|
218
|
+
Gio.DBusCallFlags.NONE,
|
|
219
|
+
-1,
|
|
220
|
+
None,
|
|
221
|
+
)
|
|
222
|
+
except _glib_error(GLib) as exc:
|
|
223
|
+
raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _returned_handle(reply: Any) -> str | None:
|
|
227
|
+
"""The request object path a Request-returning call replied with.
|
|
228
|
+
|
|
229
|
+
Every such portal method answers ``(o)``, but this stays defensive and
|
|
230
|
+
returns ``None`` on anything else: the value is only ever used as a
|
|
231
|
+
*second* path to listen on alongside the one derived from our own
|
|
232
|
+
handle_token, so a reply shaped unexpectedly is a reason to fall back to
|
|
233
|
+
that derived path, never to fail the negotiation outright.
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
unpacked = reply.unpack()
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
if isinstance(unpacked, tuple) and len(unpacked) == 1:
|
|
240
|
+
handle = unpacked[0]
|
|
241
|
+
if isinstance(handle, str):
|
|
242
|
+
return handle
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _request(
|
|
247
|
+
connection: Any,
|
|
248
|
+
Gio: Any,
|
|
249
|
+
GLib: Any,
|
|
250
|
+
busname: str,
|
|
251
|
+
interface: str,
|
|
252
|
+
method: str,
|
|
253
|
+
signature: str,
|
|
254
|
+
leading_args: tuple[Any, ...],
|
|
255
|
+
options: dict[str, Any],
|
|
256
|
+
timeout: float,
|
|
257
|
+
) -> tuple[int, Any]:
|
|
258
|
+
"""Call a Request-returning portal method, racelessly.
|
|
259
|
+
|
|
260
|
+
Subscribing to the ``Response`` signal only *after* the call that
|
|
261
|
+
returns its request handle is a real race, not a hypothetical one: a
|
|
262
|
+
fast, non-interactive response (no consent dialog involved, e.g.
|
|
263
|
+
``SelectDevices``) can arrive and be delivered before the subscription
|
|
264
|
+
is registered, hanging forever on a signal that already came and went.
|
|
265
|
+
Reproduced live (intermittent hangs at both ``SelectDevices`` and
|
|
266
|
+
``SelectSources`` in the code this was ported from). Fixed by choosing
|
|
267
|
+
the ``handle_token`` ourselves, computing the resulting request object
|
|
268
|
+
path up front, and subscribing to that exact path *before* making the
|
|
269
|
+
call at all -- the pattern xdg-desktop-portal's own documentation
|
|
270
|
+
describes.
|
|
271
|
+
|
|
272
|
+
Raises :class:`PortalTimeoutError` if no ``Response`` arrives within
|
|
273
|
+
``timeout``. The nested loop is otherwise unbounded, and a portal that
|
|
274
|
+
dies after accepting the call sends no ``Response`` and no error --
|
|
275
|
+
leaving the caller wedged with nothing to poll and no way out.
|
|
276
|
+
"""
|
|
277
|
+
unique_name = connection.get_unique_name()
|
|
278
|
+
escaped_sender = unique_name[1:].replace(".", "_")
|
|
279
|
+
token = uuid.uuid4().hex
|
|
280
|
+
options = dict(options)
|
|
281
|
+
options["handle_token"] = GLib.Variant("s", token)
|
|
282
|
+
expected_path = f"/org/freedesktop/portal/desktop/request/{escaped_sender}/{token}"
|
|
283
|
+
|
|
284
|
+
loop = GLib.MainLoop()
|
|
285
|
+
result: dict[str, Any] = {}
|
|
286
|
+
subscriptions: list[Any] = []
|
|
287
|
+
# Set inside the timeout callback rather than inferred from an empty
|
|
288
|
+
# `result` afterwards: a Response carrying no results is legitimate
|
|
289
|
+
# (SelectDevices answers with an empty dict), so "did the loop end
|
|
290
|
+
# because it timed out" has to be recorded, not deduced.
|
|
291
|
+
timed_out = False
|
|
292
|
+
|
|
293
|
+
def on_response(
|
|
294
|
+
_conn: Any,
|
|
295
|
+
_sender: Any,
|
|
296
|
+
_path: Any,
|
|
297
|
+
_iface: Any,
|
|
298
|
+
_signal: Any,
|
|
299
|
+
params: Any,
|
|
300
|
+
*_a: Any,
|
|
301
|
+
) -> None:
|
|
302
|
+
if result: # both subscriptions may fire; the first reply wins
|
|
303
|
+
return
|
|
304
|
+
result["code"], result["results"] = params.unpack()
|
|
305
|
+
loop.quit()
|
|
306
|
+
|
|
307
|
+
def on_timeout() -> bool:
|
|
308
|
+
nonlocal timed_out
|
|
309
|
+
timed_out = True
|
|
310
|
+
loop.quit()
|
|
311
|
+
return False # one-shot; GLib removes the source when this is False
|
|
312
|
+
|
|
313
|
+
def subscribe(path: str) -> None:
|
|
314
|
+
subscriptions.append(
|
|
315
|
+
connection.signal_subscribe(
|
|
316
|
+
busname,
|
|
317
|
+
_REQUEST_INTERFACE,
|
|
318
|
+
"Response",
|
|
319
|
+
path,
|
|
320
|
+
None,
|
|
321
|
+
Gio.DBusSignalFlags.NONE,
|
|
322
|
+
on_response,
|
|
323
|
+
None,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
subscribe(expected_path)
|
|
328
|
+
try:
|
|
329
|
+
parameters = GLib.Variant(signature, (*leading_args, options))
|
|
330
|
+
reply = _call_sync(
|
|
331
|
+
connection,
|
|
332
|
+
Gio,
|
|
333
|
+
GLib,
|
|
334
|
+
busname,
|
|
335
|
+
_OBJECT_PATH,
|
|
336
|
+
interface,
|
|
337
|
+
method,
|
|
338
|
+
parameters,
|
|
339
|
+
None,
|
|
340
|
+
)
|
|
341
|
+
# The spec says the handle the call returns matches the path derived
|
|
342
|
+
# from our own handle_token, but a portal is free to hand back
|
|
343
|
+
# something else -- and some do. Listening on both is strictly safer
|
|
344
|
+
# than trusting either alone: watching only the derived path means a
|
|
345
|
+
# Response delivered to the returned handle is never seen, and the
|
|
346
|
+
# wait below then runs out the full timeout for no reason.
|
|
347
|
+
handle = _returned_handle(reply)
|
|
348
|
+
if handle is not None and handle != expected_path:
|
|
349
|
+
subscribe(handle)
|
|
350
|
+
# `if not result` because a synchronous answer (a fast
|
|
351
|
+
# non-interactive Response, or a test double) can arrive during the
|
|
352
|
+
# call above, before run() is reached -- and quit() on a loop that
|
|
353
|
+
# is not running yet does not stop the later run(), so running it
|
|
354
|
+
# then would block with the reply already delivered.
|
|
355
|
+
if not result:
|
|
356
|
+
timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
|
|
357
|
+
try:
|
|
358
|
+
loop.run()
|
|
359
|
+
finally:
|
|
360
|
+
# Removing an already-fired one-shot source is harmless
|
|
361
|
+
# (GLib warns at most); leaking a live one holds a reference
|
|
362
|
+
# to this closure and fires it into a dead loop later.
|
|
363
|
+
GLib.source_remove(timeout_source)
|
|
364
|
+
finally:
|
|
365
|
+
for subscription in subscriptions:
|
|
366
|
+
connection.signal_unsubscribe(subscription)
|
|
367
|
+
if timed_out:
|
|
368
|
+
raise PortalTimeoutError(method, timeout)
|
|
369
|
+
return result["code"], result["results"]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _call_for_fd(
|
|
373
|
+
connection: Any,
|
|
374
|
+
Gio: Any,
|
|
375
|
+
GLib: Any,
|
|
376
|
+
busname: str,
|
|
377
|
+
interface: str,
|
|
378
|
+
method: str,
|
|
379
|
+
session_handle: str,
|
|
380
|
+
) -> int:
|
|
381
|
+
"""Call a method that returns a fd via a GUnixFDList index.
|
|
382
|
+
|
|
383
|
+
The fd that comes back is *owned* -- `g_unix_fd_list_get()` dups it --
|
|
384
|
+
so whoever receives it has to close it. See
|
|
385
|
+
:meth:`RemoteDesktopSession.close`.
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
reply, fd_list = connection.call_with_unix_fd_list_sync(
|
|
389
|
+
busname,
|
|
390
|
+
_OBJECT_PATH,
|
|
391
|
+
interface,
|
|
392
|
+
method,
|
|
393
|
+
GLib.Variant("(oa{sv})", (session_handle, {})),
|
|
394
|
+
GLib.VariantType.new("(h)"),
|
|
395
|
+
Gio.DBusCallFlags.NONE,
|
|
396
|
+
-1,
|
|
397
|
+
None,
|
|
398
|
+
None,
|
|
399
|
+
)
|
|
400
|
+
except _glib_error(GLib) as exc:
|
|
401
|
+
raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
|
|
402
|
+
(handle_index,) = reply.unpack()
|
|
403
|
+
return fd_list.get(handle_index)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _remote_desktop_version(connection: Any, Gio: Any, GLib: Any, busname: str) -> int:
|
|
407
|
+
reply = _call_sync(
|
|
408
|
+
connection,
|
|
409
|
+
Gio,
|
|
410
|
+
GLib,
|
|
411
|
+
busname,
|
|
412
|
+
_OBJECT_PATH,
|
|
413
|
+
"org.freedesktop.DBus.Properties",
|
|
414
|
+
"Get",
|
|
415
|
+
GLib.Variant("(ss)", (_REMOTE_DESKTOP, "version")),
|
|
416
|
+
None,
|
|
417
|
+
)
|
|
418
|
+
(version,) = reply.unpack()
|
|
419
|
+
return int(version)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class RemoteDesktopSession:
|
|
423
|
+
"""A negotiated ``org.freedesktop.portal.RemoteDesktop`` session.
|
|
424
|
+
|
|
425
|
+
Two things here need releasing, and neither happens on its own when
|
|
426
|
+
this object is dropped:
|
|
427
|
+
|
|
428
|
+
* **The portal session.** It lives in xdg-desktop-portal, not in this
|
|
429
|
+
process, and persists until ``Session.Close()`` is called or the D-Bus
|
|
430
|
+
connection that created it drops. That connection is *not* owned here
|
|
431
|
+
-- ``Gio.bus_get_sync()`` hands back GLib's shared session-bus
|
|
432
|
+
singleton, which outlives any one session -- so a long-running process
|
|
433
|
+
that negotiates repeatedly accumulates live portal sessions until it
|
|
434
|
+
exits. :meth:`close` is what ends one.
|
|
435
|
+
* **The EIS fd**, which arrives dup'd and owned. Reading :attr:`eis_fd`
|
|
436
|
+
hands that ownership on (typically straight to
|
|
437
|
+
:meth:`libei.ei.Sender.create_for_fd`, which closes it itself); if it
|
|
438
|
+
is never read, :meth:`close` closes it rather than leaking it.
|
|
439
|
+
|
|
440
|
+
Use it as a context manager, or call :meth:`close` when done.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
def __init__(
|
|
444
|
+
self,
|
|
445
|
+
connection: Any,
|
|
446
|
+
eis_fd: int,
|
|
447
|
+
restore_token: str | None,
|
|
448
|
+
session_handle: str | None = None,
|
|
449
|
+
) -> None:
|
|
450
|
+
self._connection = connection
|
|
451
|
+
self._eis_fd: int | None = eis_fd
|
|
452
|
+
self._session_handle = session_handle
|
|
453
|
+
# Mirrors libei.oeffis.Oeffis's ownership rule: reading `eis_fd`
|
|
454
|
+
# hands the fd to the caller, so close() must not also close it once
|
|
455
|
+
# that has happened -- but nothing else will ever close it if the
|
|
456
|
+
# session dies before anyone reads it, so close() must in that case.
|
|
457
|
+
self._eis_fd_claimed = False
|
|
458
|
+
self._closed = False
|
|
459
|
+
self.restore_token = restore_token
|
|
460
|
+
"""The token to pass as ``restore_token=`` on the next call to
|
|
461
|
+
avoid re-prompting, or ``None`` if the portal issued none -- either
|
|
462
|
+
because no ``persist_mode`` was requested, or the portal declined
|
|
463
|
+
to grant persistence.
|
|
464
|
+
|
|
465
|
+
Save whatever comes back on *every* run rather than only the first:
|
|
466
|
+
the portal is free to hand back a different token each time, and a
|
|
467
|
+
caller that keeps only the original would eventually present a stale
|
|
468
|
+
one. (Observed 2026-09-01 on GNOME: the same token comes back on
|
|
469
|
+
each restore. That is this portal's behaviour, not a guarantee --
|
|
470
|
+
the interface permits a new one.)
|
|
471
|
+
|
|
472
|
+
Nothing is written to disk here: a token is a standing grant of
|
|
473
|
+
input injection, so storing it is the caller's decision."""
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def eis_fd(self) -> int:
|
|
477
|
+
"""The fd to pass to :meth:`libei.ei.Sender.create_for_fd`.
|
|
478
|
+
|
|
479
|
+
Reading this transfers ownership of the fd to the caller -- after
|
|
480
|
+
that, closing it is the caller's job (or, far more usually, the
|
|
481
|
+
`Sender`'s, which takes ownership and closes it itself).
|
|
482
|
+
"""
|
|
483
|
+
if self._eis_fd is None:
|
|
484
|
+
raise PortalError("the session is closed; its EIS fd is gone")
|
|
485
|
+
self._eis_fd_claimed = True
|
|
486
|
+
return self._eis_fd
|
|
487
|
+
|
|
488
|
+
def close(self) -> None:
|
|
489
|
+
"""End the portal session, and close the EIS fd if unclaimed.
|
|
490
|
+
|
|
491
|
+
Idempotent. ``Session.Close()`` failures are logged and swallowed:
|
|
492
|
+
the session may already be gone (the portal restarted, the user
|
|
493
|
+
revoked the grant), and there is nothing a caller could usefully do
|
|
494
|
+
about it during cleanup either way.
|
|
495
|
+
|
|
496
|
+
Note that this does *not* disturb an `ei.Sender` already built on
|
|
497
|
+
the fd -- closing the portal session is what tears the EIS
|
|
498
|
+
connection down, so do it when finished injecting, not before.
|
|
499
|
+
"""
|
|
500
|
+
if self._closed:
|
|
501
|
+
return
|
|
502
|
+
self._closed = True
|
|
503
|
+
if self._eis_fd is not None and not self._eis_fd_claimed:
|
|
504
|
+
try:
|
|
505
|
+
os.close(self._eis_fd)
|
|
506
|
+
except OSError as exc:
|
|
507
|
+
# Nothing a caller could do about a failed close during
|
|
508
|
+
# cleanup, and raising here would mask whatever exception
|
|
509
|
+
# was already unwinding through a `with` block.
|
|
510
|
+
logger.debug("closing the EIS fd failed: %s", exc)
|
|
511
|
+
self._eis_fd = None
|
|
512
|
+
if self._session_handle is None or self._connection is None:
|
|
513
|
+
return
|
|
514
|
+
gio_modules = _gio()
|
|
515
|
+
if gio_modules is None: # pragma: no cover - unreachable once negotiated
|
|
516
|
+
return
|
|
517
|
+
Gio, GLib = gio_modules
|
|
518
|
+
try:
|
|
519
|
+
_call_sync(
|
|
520
|
+
self._connection,
|
|
521
|
+
Gio,
|
|
522
|
+
GLib,
|
|
523
|
+
_BUS_NAME,
|
|
524
|
+
self._session_handle,
|
|
525
|
+
_SESSION_INTERFACE,
|
|
526
|
+
"Close",
|
|
527
|
+
None,
|
|
528
|
+
None,
|
|
529
|
+
)
|
|
530
|
+
except PortalError as exc:
|
|
531
|
+
logger.debug("closing the portal session failed: %s", exc)
|
|
532
|
+
finally:
|
|
533
|
+
self._session_handle = None
|
|
534
|
+
self._connection = None
|
|
535
|
+
|
|
536
|
+
def __enter__(self) -> RemoteDesktopSession:
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
540
|
+
self.close()
|
|
541
|
+
|
|
542
|
+
def __del__(self) -> None:
|
|
543
|
+
# Deliberately only the fd, not the D-Bus half of close(): __del__
|
|
544
|
+
# can run during interpreter shutdown, where a synchronous D-Bus
|
|
545
|
+
# round trip may hang or fail in ways nothing can report. Closing an
|
|
546
|
+
# unclaimed fd is the part that is always safe and always necessary
|
|
547
|
+
# -- nothing else will ever close it. getattr() with defaults
|
|
548
|
+
# because __init__ can raise before these exist, and a bare
|
|
549
|
+
# attribute access would then raise AttributeError inside __del__,
|
|
550
|
+
# which Python only prints to stderr.
|
|
551
|
+
if getattr(self, "_eis_fd_claimed", True):
|
|
552
|
+
return
|
|
553
|
+
eis_fd = getattr(self, "_eis_fd", None)
|
|
554
|
+
if eis_fd is not None:
|
|
555
|
+
try:
|
|
556
|
+
os.close(eis_fd)
|
|
557
|
+
except OSError:
|
|
558
|
+
pass # an exception here is only printed to stderr anyway
|
|
559
|
+
|
|
560
|
+
@classmethod
|
|
561
|
+
def negotiate(
|
|
562
|
+
cls,
|
|
563
|
+
*,
|
|
564
|
+
connection: Any = None,
|
|
565
|
+
devices: DeviceType = DeviceType.ALL_DEVICES,
|
|
566
|
+
persist_mode: PersistMode = PersistMode.NONE,
|
|
567
|
+
restore_token: str | None = None,
|
|
568
|
+
busname: str = _BUS_NAME,
|
|
569
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
570
|
+
) -> RemoteDesktopSession:
|
|
571
|
+
"""Negotiate a RemoteDesktop portal session and connect it to EIS.
|
|
572
|
+
|
|
573
|
+
Blocks until the whole ``CreateSession`` -> ``SelectDevices`` ->
|
|
574
|
+
``Start`` -> ``ConnectToEIS`` sequence resolves, prompting the user
|
|
575
|
+
for consent along the way unless ``restore_token`` lets the portal
|
|
576
|
+
skip that. Raises :class:`PortalVersionError` if the compositor's
|
|
577
|
+
RemoteDesktop portal is too old for ``ConnectToEIS`` (needs v2+),
|
|
578
|
+
:class:`PortalDeniedError` if any step is declined, and
|
|
579
|
+
:class:`PortalTimeoutError` if any one round trip exceeds
|
|
580
|
+
``timeout`` seconds (60 by default -- generous, since ``Start``
|
|
581
|
+
waits on a human answering a dialog).
|
|
582
|
+
|
|
583
|
+
``devices`` selects what to ask for. ``DeviceType.ALL_DEVICES`` is
|
|
584
|
+
liboeffis's sentinel for "everything" and is literally ``0``, which
|
|
585
|
+
the portal would read as *nothing*; it is translated here to every
|
|
586
|
+
type the portal defines.
|
|
587
|
+
|
|
588
|
+
``persist_mode`` and ``restore_token`` are how a caller avoids
|
|
589
|
+
being prompted on every launch: ask for persistence, read
|
|
590
|
+
:attr:`restore_token` afterwards, store it, and hand it back next
|
|
591
|
+
time. Passing ``restore_token`` *without* a ``persist_mode`` raises
|
|
592
|
+
:class:`ValueError`: the portal answers such a request with no
|
|
593
|
+
token at all, so a caller following the store-what-comes-back rule
|
|
594
|
+
would write ``None`` over the token it just spent -- silently
|
|
595
|
+
ending the persistence it plainly meant to keep.
|
|
596
|
+
|
|
597
|
+
``connection`` can be injected (a `Gio.DBusConnection`, or a
|
|
598
|
+
test double matching its subset of methods this module calls) to
|
|
599
|
+
reuse an existing bus connection, or to test this against a fake
|
|
600
|
+
one without a real portal -- see ``tests/test_portal.py``. Left as
|
|
601
|
+
``None``, this opens a new session-bus connection itself.
|
|
602
|
+
|
|
603
|
+
No ScreenCast source is ever requested here: an absolute-pointer
|
|
604
|
+
EIS device carries its own region, and asking for ScreenCast too
|
|
605
|
+
would make the user grant screen-recording permission for nothing
|
|
606
|
+
an EIS-only caller needs.
|
|
607
|
+
"""
|
|
608
|
+
if restore_token is not None and persist_mode == PersistMode.NONE:
|
|
609
|
+
raise ValueError(
|
|
610
|
+
"restore_token was given with persist_mode=NONE: the portal "
|
|
611
|
+
"consumes a restore token on use and only issues a new one "
|
|
612
|
+
"when persistence is requested, so this would spend the "
|
|
613
|
+
"saved token and hand back None. Pass a persist_mode too."
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
gio_modules = _gio()
|
|
617
|
+
if gio_modules is None:
|
|
618
|
+
raise PortalError(
|
|
619
|
+
"PyGObject is not installed; libei.portal needs it to "
|
|
620
|
+
"negotiate a RemoteDesktop portal session "
|
|
621
|
+
"(pip install 'python-libei[portal]')"
|
|
622
|
+
)
|
|
623
|
+
Gio, GLib = gio_modules
|
|
624
|
+
|
|
625
|
+
if connection is None:
|
|
626
|
+
try:
|
|
627
|
+
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
628
|
+
except _glib_error(GLib) as exc:
|
|
629
|
+
raise PortalError(f"cannot reach the session bus: {exc}") from exc
|
|
630
|
+
|
|
631
|
+
version = _remote_desktop_version(connection, Gio, GLib, busname)
|
|
632
|
+
if version < _MIN_REMOTE_DESKTOP_VERSION:
|
|
633
|
+
raise PortalVersionError(
|
|
634
|
+
f"RemoteDesktop version {version} is too old for "
|
|
635
|
+
f"ConnectToEIS (need {_MIN_REMOTE_DESKTOP_VERSION}+)"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# session_handle_token is a *different* token from the handle_token
|
|
639
|
+
# _request() injects itself: omitting it crashes xdg-desktop-portal
|
|
640
|
+
# 1.22.1 outright (SIGABRT, "assertion failed:
|
|
641
|
+
# (session->token != NULL)") -- not optional.
|
|
642
|
+
code, results = _request(
|
|
643
|
+
connection,
|
|
644
|
+
Gio,
|
|
645
|
+
GLib,
|
|
646
|
+
busname,
|
|
647
|
+
_REMOTE_DESKTOP,
|
|
648
|
+
"CreateSession",
|
|
649
|
+
"(a{sv})",
|
|
650
|
+
(),
|
|
651
|
+
{"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},
|
|
652
|
+
timeout,
|
|
653
|
+
)
|
|
654
|
+
if code != 0:
|
|
655
|
+
raise PortalDeniedError("CreateSession")
|
|
656
|
+
session_handle = results["session_handle"]
|
|
657
|
+
|
|
658
|
+
# ALL_DEVICES is 0, which SelectDevices reads as "no device types"
|
|
659
|
+
# rather than "every device type" -- see _ALL_DEVICE_TYPES.
|
|
660
|
+
types = _ALL_DEVICE_TYPES if devices == DeviceType.ALL_DEVICES else devices
|
|
661
|
+
options: dict[str, Any] = {"types": GLib.Variant("u", int(types))}
|
|
662
|
+
if persist_mode != PersistMode.NONE:
|
|
663
|
+
options["persist_mode"] = GLib.Variant("u", int(persist_mode))
|
|
664
|
+
if restore_token is not None:
|
|
665
|
+
options["restore_token"] = GLib.Variant("s", restore_token)
|
|
666
|
+
code, _results = _request(
|
|
667
|
+
connection,
|
|
668
|
+
Gio,
|
|
669
|
+
GLib,
|
|
670
|
+
busname,
|
|
671
|
+
_REMOTE_DESKTOP,
|
|
672
|
+
"SelectDevices",
|
|
673
|
+
"(oa{sv})",
|
|
674
|
+
(session_handle,),
|
|
675
|
+
options,
|
|
676
|
+
timeout,
|
|
677
|
+
)
|
|
678
|
+
if code != 0:
|
|
679
|
+
raise PortalDeniedError("SelectDevices")
|
|
680
|
+
|
|
681
|
+
code, results = _request(
|
|
682
|
+
connection,
|
|
683
|
+
Gio,
|
|
684
|
+
GLib,
|
|
685
|
+
busname,
|
|
686
|
+
_REMOTE_DESKTOP,
|
|
687
|
+
"Start",
|
|
688
|
+
"(osa{sv})",
|
|
689
|
+
(session_handle, ""),
|
|
690
|
+
{},
|
|
691
|
+
timeout,
|
|
692
|
+
)
|
|
693
|
+
if code != 0:
|
|
694
|
+
raise PortalDeniedError(
|
|
695
|
+
"Start", "the user declined the remote-control consent dialog"
|
|
696
|
+
)
|
|
697
|
+
new_restore_token = results.get("restore_token")
|
|
698
|
+
|
|
699
|
+
eis_fd = _call_for_fd(
|
|
700
|
+
connection,
|
|
701
|
+
Gio,
|
|
702
|
+
GLib,
|
|
703
|
+
busname,
|
|
704
|
+
_REMOTE_DESKTOP,
|
|
705
|
+
"ConnectToEIS",
|
|
706
|
+
session_handle,
|
|
707
|
+
)
|
|
708
|
+
return cls(connection, eis_fd, new_restore_token, session_handle)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
12
|
Classifier: Development Status :: 3 - Alpha
|
|
12
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -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,8 +136,9 @@ pointer.
|
|
|
133
136
|
|
|
134
137
|
## Status
|
|
135
138
|
|
|
136
|
-
Alpha (`0.
|
|
137
|
-
renames before 1.0. What
|
|
139
|
+
Alpha (`0.3.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
140
|
+
since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
|
|
141
|
+
that qualifier covers, concretely:
|
|
138
142
|
|
|
139
143
|
- The injection path — connect, bind, wait for a device, send events — is
|
|
140
144
|
exercised end-to-end against the real libraries by
|
|
@@ -143,9 +147,20 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
143
147
|
- Text input, touch cancellation, ping/pong, keymap transfer, region mapping
|
|
144
148
|
ids and `peek_event_type()` are each round-tripped through a real libeis
|
|
145
149
|
server in `tests/test_integration_extras.py`.
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
155
|
+
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
156
|
+
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
157
|
+
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
158
|
+
(`RemoteDesktop` v2): a first run raised the consent dialog and was
|
|
159
|
+
approved (5.4s), a second replaying the `restore_token` was granted with
|
|
160
|
+
no dialog at all (0.2s), three devices resumed on the returned fd
|
|
161
|
+
(relative pointer, keyboard, absolute pointer — in that order, the device
|
|
162
|
+
race `ei`-side callers must handle), and `Session.Close()` was exercised.
|
|
163
|
+
No input was injected — emulation is `libei.ei`'s job.
|
|
149
164
|
- Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
|
|
150
165
|
locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
|
|
151
166
|
gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
|
|
@@ -157,7 +172,7 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
157
172
|
| Instead of this | Why you might |
|
|
158
173
|
| --- | --- |
|
|
159
174
|
| [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. |
|
|
160
|
-
| The portal's D-Bus API directly (`
|
|
175
|
+
| 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. |
|
|
161
176
|
| `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. |
|
|
162
177
|
|
|
163
178
|
## Requirements
|
|
@@ -165,6 +180,10 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
165
180
|
- Linux with a Wayland compositor (GNOME, KDE, Sway, …)
|
|
166
181
|
- CPython 3.10 or newer (tested on 3.13)
|
|
167
182
|
- The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
|
|
183
|
+
- `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
|
|
184
|
+
whatever GObject-introspection libraries your distro needs for `Gio` --
|
|
185
|
+
PyPI's PyGObject wheel supplies the Python side only. Not needed for
|
|
186
|
+
`libei.ei`, `libei.eis` or `libei.oeffis`.
|
|
168
187
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
169
188
|
sending pointer, button, keyboard, scroll and touch input all use symbols
|
|
170
189
|
that have existed with a stable signature since 1.0.0, and upstream keeps
|
|
@@ -194,17 +213,29 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
194
213
|
|
|
195
214
|
## Install
|
|
196
215
|
|
|
197
|
-
|
|
216
|
+
From [PyPI](https://pypi.org/project/python-libei/):
|
|
198
217
|
|
|
199
218
|
```sh
|
|
200
|
-
|
|
201
|
-
cd python-libei
|
|
202
|
-
pip install .
|
|
219
|
+
pip install python-libei
|
|
203
220
|
```
|
|
204
221
|
|
|
205
222
|
The distribution is named `python-libei`, the import is `libei` -- so
|
|
206
223
|
`pip show python-libei`, but `from libei import ei`.
|
|
207
224
|
|
|
225
|
+
Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
|
|
226
|
+
the native libraries directly, so there is no compiler, no headers and no
|
|
227
|
+
`libei-devel` involved at install time. What `pip` does *not* bring is the
|
|
228
|
+
native libraries themselves -- see [Requirements](#requirements) above; on
|
|
229
|
+
Fedora, `sudo dnf install libei libeis liboeffis`.
|
|
230
|
+
|
|
231
|
+
To track `main` instead, or to hack on it, install from a checkout:
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
git clone https://github.com/ctrondlp/python-libei.git
|
|
235
|
+
cd python-libei
|
|
236
|
+
pip install . # or `pip install -e '.[dev]'` to develop
|
|
237
|
+
```
|
|
238
|
+
|
|
208
239
|
Importing is always safe, even where the native libraries are missing — they
|
|
209
240
|
are loaded on first use, not at import. Check before you rely on them:
|
|
210
241
|
|
|
@@ -288,23 +319,59 @@ several devices — see the absolute-positioning notes under
|
|
|
288
319
|
call and exposes no options dict, so the two things that make an approval
|
|
289
320
|
persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
|
|
290
321
|
comes back on `Start` — are unreachable through it. This is a limitation of
|
|
291
|
-
the C library, not of these bindings;
|
|
322
|
+
the C library, not of these bindings; upstream's own docs say as much:
|
|
323
|
+
liboeffis is "intentionally kept simple, any more complex needs should be
|
|
324
|
+
handled by an application talking to DBus directly"
|
|
325
|
+
([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
|
|
292
326
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
327
|
+
`libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
|
|
328
|
+
→ `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
|
|
329
|
+
`pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
|
|
330
|
+
as real parameters:
|
|
296
331
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
332
|
+
```python
|
|
333
|
+
from libei import ei, portal
|
|
334
|
+
|
|
335
|
+
with portal.RemoteDesktopSession.negotiate(
|
|
336
|
+
devices=portal.DeviceType.POINTER,
|
|
337
|
+
persist_mode=portal.PersistMode.UNTIL_REVOKED,
|
|
338
|
+
restore_token=saved_token, # None on the first run
|
|
339
|
+
) as session:
|
|
340
|
+
save_somewhere(session.restore_token) # a fresh token every time -- save it
|
|
341
|
+
sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
|
|
342
|
+
... # inject input for as long as the session is needed
|
|
343
|
+
```
|
|
302
344
|
|
|
303
345
|
Save the token somewhere durable and pass it back next time; the portal then
|
|
304
346
|
restores the session without prompting. Treat it as a credential — anyone
|
|
305
347
|
holding it can reopen input injection on that desktop, so it belongs
|
|
306
348
|
wherever you'd keep a password, and the decision to store it at all belongs
|
|
307
|
-
to the application rather than to
|
|
349
|
+
to the application rather than to this library, which never writes it
|
|
350
|
+
anywhere itself.
|
|
351
|
+
|
|
352
|
+
Save whatever comes back on **every** run, not just the first: the portal is
|
|
353
|
+
free to hand back a different token each time, and a caller that keeps only
|
|
354
|
+
the original would eventually present a stale one. (On GNOME the same token
|
|
355
|
+
comes back on each restore — that is one portal's behaviour, not a
|
|
356
|
+
guarantee.) Passing `restore_token` *without* a `persist_mode` raises
|
|
357
|
+
`ValueError`: the portal answers such a request with no token at all, so
|
|
358
|
+
storing what came back would write `None` over the token you just spent.
|
|
359
|
+
|
|
360
|
+
Three differences from `Oeffis` above worth knowing:
|
|
361
|
+
|
|
362
|
+
- **Blocking, not event-driven.** `negotiate()` runs its own nested
|
|
363
|
+
`GLib.MainLoop` per D-Bus round trip and returns only once connected, or
|
|
364
|
+
raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
|
|
365
|
+
- **Bounded.** Each round trip gets `timeout` seconds (60 by default —
|
|
366
|
+
generous, since `Start` waits on a human answering a dialog). Without it a
|
|
367
|
+
portal that dies after accepting the call would wedge the calling thread
|
|
368
|
+
forever, which is the one thing `Oeffis`'s pollable fd protects against.
|
|
369
|
+
- **Close it.** The portal session lives in xdg-desktop-portal and outlives
|
|
370
|
+
the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
|
|
371
|
+
back GLib's *shared* connection, so dropping the session tears nothing
|
|
372
|
+
down, and a long-running process that negotiates repeatedly accumulates
|
|
373
|
+
live sessions. The `with` block above handles it; otherwise call
|
|
374
|
+
`session.close()`.
|
|
308
375
|
|
|
309
376
|
## Sending input
|
|
310
377
|
|
|
@@ -633,6 +700,7 @@ the capability you bound (`seat.capabilities`).
|
|
|
633
700
|
| `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
|
|
634
701
|
| `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
|
|
635
702
|
| `libei.oeffis` | Getting an EI fd from the desktop portal |
|
|
703
|
+
| `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
|
|
636
704
|
|
|
637
705
|
Each module has `is_available()`, an `Error` exception, and an `EventType` /
|
|
638
706
|
`DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
|
|
@@ -657,6 +725,12 @@ them in this order -- each one only makes sense once the one below it does.
|
|
|
657
725
|
| [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
|
|
658
726
|
| [`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 |
|
|
659
727
|
|
|
728
|
+
[`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
|
|
729
|
+
is no C library behind it, so no `_capi` binding and no `CObject`. It talks
|
|
730
|
+
D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
|
|
731
|
+
the C libraries are loaded lazily) and only ever produces a plain fd, which
|
|
732
|
+
is where it hands off to `ei.Sender.create_for_fd()`.
|
|
733
|
+
|
|
660
734
|
**Read `_cobject.py` first.** It is the smallest file with the most
|
|
661
735
|
consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
|
|
662
736
|
`_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
|
|
@@ -721,16 +795,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
|
|
|
721
795
|
`src/libei/__init__.py` -- which have to agree with each other and with the
|
|
722
796
|
tag. Nothing enforces that yet.
|
|
723
797
|
|
|
724
|
-
A release is an annotated, `v`-prefixed tag
|
|
798
|
+
A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
|
|
799
|
+
PyPI is the only place a release is published, and no GitHub Release is cut:
|
|
725
800
|
|
|
726
801
|
```sh
|
|
727
|
-
git tag -a v0.
|
|
728
|
-
git push origin v0.
|
|
729
|
-
gh release create v0.1.0 --generate-notes --prerelease
|
|
802
|
+
git tag -a v0.2.0 -m "0.2.0"
|
|
803
|
+
git push origin v0.2.0
|
|
730
804
|
```
|
|
731
805
|
|
|
732
|
-
|
|
733
|
-
|
|
806
|
+
While the API is unfrozen, the pre-release signal lives in the version
|
|
807
|
+
itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
|
|
808
|
+
python-libei` off it, and a `0.x` version already says the API can move.
|
|
734
809
|
|
|
735
810
|
Publishing runs from CI on a `v*` tag using PyPI
|
|
736
811
|
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
|
|
@@ -739,22 +814,24 @@ job in `ci.yml` handles it, uploading the artifacts the `build` job already
|
|
|
739
814
|
ran `twine check` over.
|
|
740
815
|
|
|
741
816
|
That job depends on two pieces of configuration outside this repository,
|
|
742
|
-
which
|
|
743
|
-
|
|
744
|
-
1. On pypi.org,
|
|
745
|
-
|
|
746
|
-
`
|
|
747
|
-
|
|
817
|
+
both of which are in place as of `0.1.0`:
|
|
818
|
+
|
|
819
|
+
1. On pypi.org, a trusted publisher on the `python-libei` project: owner
|
|
820
|
+
`ctrondlp`, repository `python-libei`, workflow `ci.yml`, environment
|
|
821
|
+
`pypi`. It started life as a **pending** publisher -- the flow for a
|
|
822
|
+
project with no releases yet -- and the first upload converted it into
|
|
823
|
+
an ordinary project-level one, so a fresh project is the only case that
|
|
824
|
+
needs the pending form again. Every field has to match the workflow
|
|
748
825
|
exactly; a mismatch surfaces as a rejected credential at upload time,
|
|
749
826
|
not when it is saved.
|
|
750
827
|
2. A GitHub environment named `pypi`, in the repository settings. A
|
|
751
828
|
required reviewer on it makes each publish a deliberate approval rather
|
|
752
829
|
than a side effect of pushing a tag.
|
|
753
830
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
831
|
+
PyPI filenames are immutable, so a bad upload can only be yanked and
|
|
832
|
+
superseded by a new version, never replaced -- worth rehearsing anything
|
|
833
|
+
unusual on TestPyPI first (separate account, separate pending publisher,
|
|
834
|
+
and `repository-url: https://test.pypi.org/legacy/` on the publish step).
|
|
758
835
|
|
|
759
836
|
## Design notes
|
|
760
837
|
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
libei/__init__.py,sha256=
|
|
1
|
+
libei/__init__.py,sha256=ckwB3oooo2C06cgm5CFk182ieSwvWwcRtazRfmEs6nw,1676
|
|
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=Vf0HeI5kjvTBlCv5P531ugDqclMdL5o9Qru_T80VFq4,27679
|
|
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.
|
|
13
|
-
python_libei-0.
|
|
14
|
-
python_libei-0.
|
|
15
|
-
python_libei-0.
|
|
16
|
-
python_libei-0.
|
|
13
|
+
python_libei-0.3.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
|
|
14
|
+
python_libei-0.3.0.dist-info/METADATA,sha256=ATlj4wSdgfKN1MOaeQa8h27ss1xC3FxrouZ3hZqSWAI,40033
|
|
15
|
+
python_libei-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
python_libei-0.3.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
|
|
17
|
+
python_libei-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|