python-libei 0.1.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 +31 -0
- libei/_capi/__init__.py +6 -0
- libei/_capi/libei.py +247 -0
- libei/_capi/libeis.py +285 -0
- libei/_capi/liboeffis.py +29 -0
- libei/_capi/loader.py +112 -0
- libei/_cobject.py +252 -0
- libei/ei.py +1244 -0
- libei/eis.py +1234 -0
- libei/oeffis.py +238 -0
- libei/py.typed +0 -0
- python_libei-0.1.0.dist-info/METADATA +770 -0
- python_libei-0.1.0.dist-info/RECORD +16 -0
- python_libei-0.1.0.dist-info/WHEEL +5 -0
- python_libei-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_libei-0.1.0.dist-info/top_level.txt +1 -0
libei/oeffis.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Pythonic wrapper around liboeffis -- negotiates an EIS connection through
|
|
2
|
+
the ``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal.
|
|
3
|
+
|
|
4
|
+
This is the path a sandboxed or otherwise non-privileged client uses to get
|
|
5
|
+
an EI socket: it asks the portal, the user is shown a consent dialog, and on
|
|
6
|
+
approval this hands back a file descriptor to pass to
|
|
7
|
+
:meth:`libei.ei.Sender.create_for_fd`.
|
|
8
|
+
|
|
9
|
+
oeffis = Oeffis.create(devices=DeviceType.POINTER)
|
|
10
|
+
while True:
|
|
11
|
+
ready, _, _ = select.select([oeffis.fd], [], [], timeout)
|
|
12
|
+
if not ready:
|
|
13
|
+
continue
|
|
14
|
+
if oeffis.dispatch():
|
|
15
|
+
break
|
|
16
|
+
sender = ei.Sender.create_for_fd(oeffis.eis_fd, name="my-app")
|
|
17
|
+
|
|
18
|
+
Two limitations worth knowing before building on this:
|
|
19
|
+
|
|
20
|
+
* **Every run prompts.** The portal supports remembering an approval --
|
|
21
|
+
``SelectDevices`` takes a ``persist_mode`` and ``Start`` returns a
|
|
22
|
+
``restore_token`` to replay next time -- but liboeffis exposes neither:
|
|
23
|
+
``oeffis_create_session()`` takes a device-type bitmask and nothing else.
|
|
24
|
+
A caller that must not re-prompt has to drive
|
|
25
|
+
``org.freedesktop.portal.RemoteDesktop`` over D-Bus itself and pass the
|
|
26
|
+
resulting fd to :meth:`libei.ei.Sender.create_for_fd`, which does not
|
|
27
|
+
care how the fd was obtained. See the README section "Avoiding the
|
|
28
|
+
consent dialog on every run".
|
|
29
|
+
* **Least verified path here.** In live testing this has been the least
|
|
30
|
+
reliable part of the underlying libraries (see the project README) --
|
|
31
|
+
treat failures as possibly environment-specific rather than necessarily
|
|
32
|
+
a bug in this wrapper.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import enum
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
from . import _capi
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("libei.oeffis")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_available() -> bool:
|
|
47
|
+
"""Whether liboeffis.so.1 can be loaded on this system."""
|
|
48
|
+
return _capi.liboeffis.lib.is_available()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DisconnectedError(Exception):
|
|
52
|
+
"""The portal session ended unexpectedly (error, or denied by the user)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str | None) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.message = message
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionClosedError(DisconnectedError):
|
|
60
|
+
"""The portal explicitly closed the session (not necessarily an error)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
super().__init__(message="Session closed")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DeviceType(enum.IntFlag):
|
|
67
|
+
"""Device types to request from the portal, OR'd together.
|
|
68
|
+
|
|
69
|
+
Mirrors ``enum oeffis_device`` from liboeffis.h. Unlike libei's own
|
|
70
|
+
capability lists, these *are* passed to C as a single OR'd bitmask --
|
|
71
|
+
``oeffis_create_session()`` takes one ``uint32_t``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
ALL_DEVICES = 0
|
|
75
|
+
KEYBOARD = 1
|
|
76
|
+
POINTER = 2
|
|
77
|
+
TOUCHSCREEN = 4
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _EventType(enum.IntEnum):
|
|
81
|
+
"""Mirrors ``enum oeffis_event_type``; also used as this object's state.
|
|
82
|
+
|
|
83
|
+
:meth:`Oeffis.dispatch` stores the last event it saw in ``_state``, so
|
|
84
|
+
the terminal ones (CLOSED, DISCONNECTED) keep raising on every later
|
|
85
|
+
call rather than silently doing nothing.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
NONE = 0
|
|
89
|
+
CONNECTED_TO_EIS = 1
|
|
90
|
+
CLOSED = 2
|
|
91
|
+
DISCONNECTED = 3
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Oeffis:
|
|
95
|
+
"""Wraps a liboeffis context for one portal session.
|
|
96
|
+
|
|
97
|
+
Must be kept alive for the duration of the session -- destroying it
|
|
98
|
+
closes the session and invalidates ``eis_fd`` for any :mod:`libei.ei`
|
|
99
|
+
context still using it.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
pointer = _capi.liboeffis.new(None)
|
|
104
|
+
if not pointer:
|
|
105
|
+
raise DisconnectedError("oeffis_new() returned NULL")
|
|
106
|
+
self._pointer = pointer
|
|
107
|
+
self._eis_fd: int | None = None
|
|
108
|
+
# Set the first (and only the first) time the `eis_fd` property is
|
|
109
|
+
# read -- reading it hands the fd to the caller (typically to pass
|
|
110
|
+
# straight to Sender.create_for_fd(), which takes ownership and
|
|
111
|
+
# closes it itself), so __del__ must not also close it once that's
|
|
112
|
+
# happened. But oeffis_get_eis_fd() docs say the caller owns the
|
|
113
|
+
# dup()'d fd it returns, and if the session dies (or this object is
|
|
114
|
+
# just dropped) before anyone ever reads `eis_fd`, nothing else
|
|
115
|
+
# will ever close it -- __del__ closes it itself in that case.
|
|
116
|
+
self._eis_fd_claimed = False
|
|
117
|
+
self._state = _EventType.NONE
|
|
118
|
+
|
|
119
|
+
def __del__(self) -> None:
|
|
120
|
+
# getattr() with defaults rather than plain attribute access:
|
|
121
|
+
# __init__ raises DisconnectedError when oeffis_new() returns NULL,
|
|
122
|
+
# and Python still calls __del__ on the half-built object, where
|
|
123
|
+
# none of these attributes exist yet. A bare self._eis_fd would
|
|
124
|
+
# raise AttributeError inside __del__ -- which Python swallows to
|
|
125
|
+
# stderr -- and skip the unref below.
|
|
126
|
+
eis_fd = getattr(self, "_eis_fd", None)
|
|
127
|
+
# The `True` default is the fail-safe direction: if the attribute is
|
|
128
|
+
# somehow missing, assume the fd was claimed and leave it alone.
|
|
129
|
+
# Not closing an fd we own leaks one; closing one the caller already
|
|
130
|
+
# handed to ei_setup_backend_fd() would break a live connection, or
|
|
131
|
+
# close an unrelated fd that has since reused the number.
|
|
132
|
+
if eis_fd is not None and not getattr(self, "_eis_fd_claimed", True):
|
|
133
|
+
os.close(eis_fd)
|
|
134
|
+
pointer = getattr(self, "_pointer", None)
|
|
135
|
+
if pointer:
|
|
136
|
+
_capi.liboeffis.unref(pointer)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def fd(self) -> int:
|
|
140
|
+
"""Poll this fd; call :meth:`dispatch` whenever it's readable."""
|
|
141
|
+
return _capi.liboeffis.get_fd(self._pointer)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def eis_fd(self) -> int:
|
|
145
|
+
"""The fd to pass to :meth:`libei.ei.Sender.create_for_fd`.
|
|
146
|
+
|
|
147
|
+
Raises :class:`DisconnectedError` if accessed before
|
|
148
|
+
:meth:`dispatch` has returned ``True``.
|
|
149
|
+
"""
|
|
150
|
+
if self._state != _EventType.CONNECTED_TO_EIS:
|
|
151
|
+
raise DisconnectedError(self.error_message)
|
|
152
|
+
assert self._eis_fd is not None
|
|
153
|
+
self._eis_fd_claimed = True
|
|
154
|
+
return self._eis_fd
|
|
155
|
+
|
|
156
|
+
def dispatch(self) -> bool:
|
|
157
|
+
"""Process pending events; return True once connected to EIS.
|
|
158
|
+
|
|
159
|
+
Raises :class:`DisconnectedError` or :class:`SessionClosedError` if
|
|
160
|
+
the session ended; further calls after that keep raising the same
|
|
161
|
+
exception rather than silently doing nothing.
|
|
162
|
+
"""
|
|
163
|
+
if self._state == _EventType.CLOSED:
|
|
164
|
+
raise SessionClosedError()
|
|
165
|
+
if self._state == _EventType.DISCONNECTED:
|
|
166
|
+
raise DisconnectedError(self.error_message)
|
|
167
|
+
|
|
168
|
+
_capi.liboeffis.dispatch(self._pointer)
|
|
169
|
+
while True:
|
|
170
|
+
raw_event = _capi.liboeffis.get_event(self._pointer)
|
|
171
|
+
try:
|
|
172
|
+
event = _EventType(raw_event)
|
|
173
|
+
except ValueError:
|
|
174
|
+
# Same contract as ei/eis EventType: an event value this
|
|
175
|
+
# table doesn't know about must not crash the caller. Skip
|
|
176
|
+
# it and keep draining rather than raising out of dispatch.
|
|
177
|
+
logger.debug("ignoring unknown oeffis event type %d", raw_event)
|
|
178
|
+
continue
|
|
179
|
+
if event == _EventType.NONE:
|
|
180
|
+
return False
|
|
181
|
+
if event == _EventType.CONNECTED_TO_EIS:
|
|
182
|
+
eis_fd = _capi.liboeffis.get_eis_fd(self._pointer)
|
|
183
|
+
if eis_fd < 0:
|
|
184
|
+
# Documented as "-1 on failure or before the fd was
|
|
185
|
+
# retrieved". Treating that as a live fd would hand -1
|
|
186
|
+
# to ei_setup_backend_fd() and fail far from the cause.
|
|
187
|
+
self._state = _EventType.DISCONNECTED
|
|
188
|
+
raise DisconnectedError(
|
|
189
|
+
self.error_message
|
|
190
|
+
or "oeffis_get_eis_fd() failed after CONNECTED_TO_EIS"
|
|
191
|
+
)
|
|
192
|
+
self._eis_fd = eis_fd
|
|
193
|
+
self._state = _EventType.CONNECTED_TO_EIS
|
|
194
|
+
return True
|
|
195
|
+
if event == _EventType.DISCONNECTED:
|
|
196
|
+
self._state = _EventType.DISCONNECTED
|
|
197
|
+
raise DisconnectedError(self.error_message)
|
|
198
|
+
if event == _EventType.CLOSED:
|
|
199
|
+
self._state = _EventType.CLOSED
|
|
200
|
+
raise SessionClosedError()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def error_message(self) -> str | None:
|
|
204
|
+
"""The last error liboeffis reported, or ``None`` if it has none.
|
|
205
|
+
|
|
206
|
+
Populated when a session disconnects or fails; a healthy session
|
|
207
|
+
normally reports nothing, but ``None`` only ever means "no message
|
|
208
|
+
available", not "no error occurred".
|
|
209
|
+
"""
|
|
210
|
+
message = _capi.liboeffis.get_error_message(self._pointer)
|
|
211
|
+
return message.decode("utf-8") if message else None
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def create(
|
|
215
|
+
cls,
|
|
216
|
+
devices: DeviceType = DeviceType.ALL_DEVICES,
|
|
217
|
+
busname: str = "org.freedesktop.portal.Desktop",
|
|
218
|
+
) -> Oeffis:
|
|
219
|
+
"""Start a RemoteDesktop portal session request.
|
|
220
|
+
|
|
221
|
+
Returns immediately -- the portal typically prompts the user for
|
|
222
|
+
consent, so poll :attr:`fd` and call :meth:`dispatch` until it
|
|
223
|
+
returns ``True`` before reading :attr:`eis_fd`.
|
|
224
|
+
"""
|
|
225
|
+
session = cls()
|
|
226
|
+
_capi.liboeffis.create_session_on_bus(
|
|
227
|
+
session._pointer, busname.encode("utf-8"), devices
|
|
228
|
+
)
|
|
229
|
+
return session
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"DeviceType",
|
|
234
|
+
"DisconnectedError",
|
|
235
|
+
"Oeffis",
|
|
236
|
+
"SessionClosedError",
|
|
237
|
+
"is_available",
|
|
238
|
+
]
|
libei/py.typed
ADDED
|
File without changes
|