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/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