python-libei 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_libei-0.6.0/src/python_libei.egg-info → python_libei-0.7.0}/PKG-INFO +39 -20
- {python_libei-0.6.0 → python_libei-0.7.0}/README.md +33 -17
- {python_libei-0.6.0 → python_libei-0.7.0}/pyproject.toml +7 -2
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/__init__.py +1 -1
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_capi/libei.py +5 -3
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_capi/libeis.py +6 -4
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_capi/loader.py +34 -1
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/portal.py +64 -7
- {python_libei-0.6.0 → python_libei-0.7.0/src/python_libei.egg-info}/PKG-INFO +39 -20
- {python_libei-0.6.0 → python_libei-0.7.0}/src/python_libei.egg-info/SOURCES.txt +4 -0
- python_libei-0.7.0/tests/test_abi.py +507 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_ei_objects.py +4 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_eis_objects.py +4 -0
- python_libei-0.7.0/tests/test_event_accessors.py +177 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_inputcapture.py +97 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_integration_extras.py +4 -0
- python_libei-0.7.0/tests/test_integration_frame.py +104 -0
- python_libei-0.7.0/tests/test_integration_lifetime.py +49 -0
- python_libei-0.7.0/tests/test_loader.py +198 -0
- python_libei-0.6.0/tests/test_loader.py +0 -81
- {python_libei-0.6.0 → python_libei-0.7.0}/LICENSE +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/setup.cfg +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/_cobject.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/ei.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/eis.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/oeffis.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/libei/py.typed +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/python_libei.egg-info/requires.txt +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_cobject.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_documentation_shape.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_documented_examples.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_oeffis.py +0 -0
- {python_libei-0.6.0 → python_libei-0.7.0}/tests/test_portal.py +0 -0
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Inject and
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
|
|
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: documentation, https://github.com/ctrondlp/python-libei/tree/main/docs
|
|
11
|
+
Project-URL: pyguitest, https://github.com/ctrondlp/pyguitest
|
|
10
12
|
Project-URL: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
|
|
13
|
+
Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,input-capture,eis,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
|
|
12
14
|
Classifier: Development Status :: 4 - Beta
|
|
13
15
|
Classifier: Intended Audience :: Developers
|
|
14
16
|
Classifier: Operating System :: POSIX :: BSD :: FreeBSD
|
|
15
17
|
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Operating System :: POSIX
|
|
16
19
|
Classifier: Programming Language :: Python :: 3
|
|
17
20
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
21
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -37,6 +40,7 @@ Dynamic: license-file
|
|
|
37
40
|
|
|
38
41
|
# python-libei
|
|
39
42
|
|
|
43
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
40
44
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
41
45
|
[](https://pypi.org/project/python-libei/)
|
|
42
46
|
[](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
|
|
@@ -53,8 +57,11 @@ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
|
|
|
53
57
|
```
|
|
54
58
|
|
|
55
59
|
**The one concept:** events queue up, and **`frame()` is what sends them** as
|
|
56
|
-
one logical hardware event. Forget it and
|
|
57
|
-
warning, no movement.
|
|
60
|
+
one logical hardware event. Forget it and the motion is usually lost — no
|
|
61
|
+
exception, no warning, no movement. (Ending the chain with `stop_emulating()`
|
|
62
|
+
makes libei frame the queue for you, but it logs an error-level `Bug:` line
|
|
63
|
+
when it does; see [Things that will bite you](#things-that-will-bite-you).) Fill
|
|
64
|
+
the package, then post it.
|
|
58
65
|
|
|
59
66
|
New here? [docs/getting-started.md](docs/getting-started.md) is install
|
|
60
67
|
through a first real pointer motion, in five minutes.
|
|
@@ -96,8 +103,8 @@ than a widget-tree back door:
|
|
|
96
103
|
|
|
97
104
|
## Which API do I need?
|
|
98
105
|
|
|
99
|
-
|
|
100
|
-
to get permission, then `ei` to inject.
|
|
106
|
+
Four modules (`ei`, `eis`, `oeffis`, `portal`), and most callers need exactly
|
|
107
|
+
two of them: `oeffis` or `portal` to get permission, then `ei` to inject.
|
|
101
108
|
|
|
102
109
|
| You want to… | Use | Notes |
|
|
103
110
|
| --- | --- | --- |
|
|
@@ -105,6 +112,7 @@ to get permission, then `ei` to inject.
|
|
|
105
112
|
| **Consume input** from a compositor | `libei.ei` → `Receiver` | Compositor-side or input-capture code; same connection dance |
|
|
106
113
|
| **Get permission**, simply | `libei.oeffis` | One call, pollable fd, no dependencies. The consent dialog appears on **every** run |
|
|
107
114
|
| **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
|
|
115
|
+
| **Capture the user's real input** | `libei.portal` → `InputCaptureSession` | The read direction, through the InputCapture portal: once the compositor decides to, the user's pointer and keyboard are diverted to you over EIS. Exclusive while active, so hold it briefly. Only its negotiation half has been run against a real desktop — see [verification](docs/developers/verification.md) |
|
|
108
116
|
| **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
|
|
109
117
|
|
|
110
118
|
Each module has `is_available()`. `ei` and `eis` share the shapes around them:
|
|
@@ -143,8 +151,9 @@ because a skimming reader could otherwise take them as supported. 1.6.0's own
|
|
|
143
151
|
`enum ei_device_capability` stops at `TEXT`, and its `enum ei_event_type`
|
|
144
152
|
stops at `EI_EVENT_TEXT_UTF8` — so the swipe/pinch/hold/stylus members of
|
|
145
153
|
`EventType` cannot arrive either. The values here match upstream `main`
|
|
146
|
-
exactly, so they are ready for whatever release adds them. The
|
|
147
|
-
|
|
154
|
+
exactly, so they are ready for whatever release adds them. The gesture
|
|
155
|
+
and stylus functions `main` adds -- 46 in libei and 51 in libeis against
|
|
156
|
+
1.6.0's headers, counted 2026-09-30 -- are deliberately not bound:
|
|
148
157
|
nothing that ships today exports them, so nothing here could be verified
|
|
149
158
|
against a real library, which is the bar every other binding in this package
|
|
150
159
|
was held to.
|
|
@@ -166,7 +175,7 @@ and why, is in
|
|
|
166
175
|
|
|
167
176
|
## Status
|
|
168
177
|
|
|
169
|
-
Beta (`0.
|
|
178
|
+
Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
170
179
|
since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
|
|
171
180
|
|
|
172
181
|
The injection path is exercised end to end against the real libraries by the
|
|
@@ -207,9 +216,13 @@ Exactly what was run, when, and against which versions:
|
|
|
207
216
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
208
217
|
sending pointer, button, keyboard, scroll and touch input all use symbols
|
|
209
218
|
that have existed with a stable signature since 1.0.0, and upstream keeps
|
|
210
|
-
API/ABI back-compatible within the 1.x series.
|
|
211
|
-
|
|
212
|
-
the injection path passes the full suite with
|
|
219
|
+
API/ABI back-compatible within the 1.x series. The suite has been run
|
|
220
|
+
against 1.2.1 (Ubuntu 24.04, in CI) and 1.6.0 -- the latter on Fedora 44 and
|
|
221
|
+
45 and FreeBSD 15, where the injection path passes the full suite with
|
|
222
|
+
nothing skipped. Every binding's name, argument types and return type, and
|
|
223
|
+
every enum value, is also checked against the upstream headers of 1.0.0,
|
|
224
|
+
1.2.1, 1.4.0, 1.5.0 and 1.6.0 (`tests/test_abi.py`), which is the only
|
|
225
|
+
coverage 1.0.0, 1.4.0 and 1.5.0 have had.
|
|
213
226
|
|
|
214
227
|
Newer libei buys you more, per feature:
|
|
215
228
|
|
|
@@ -367,8 +380,12 @@ All four, with working code:
|
|
|
367
380
|
soon as the loop moves on, and using it afterwards raises `RuntimeError`.
|
|
368
381
|
Objects you pull *off* an event (`event.device`, `event.seat`) are safe to
|
|
369
382
|
keep — copy out `event.pointer_event` and friends rather than the event.
|
|
370
|
-
- **`frame()` or
|
|
371
|
-
them
|
|
383
|
+
- **`frame()` or the input is lost.** Events queue up until a `frame()` commits
|
|
384
|
+
them, and queued events that are never framed are dropped without an
|
|
385
|
+
exception or a log line. The one exception is a chain that ends in
|
|
386
|
+
`stop_emulating()`: libei frames what is queued, delivers it, and logs
|
|
387
|
+
`Bug: ei_device_stop_emulating: missing call to ei_device_frame()` at error
|
|
388
|
+
level. Measured against libei 1.2.1 and 1.6.0; don't rely on it.
|
|
372
389
|
- **`bind()` needs at least one capability.** Binding an empty set sends
|
|
373
390
|
nothing, so the device you are waiting for never arrives; this raises
|
|
374
391
|
`ValueError` rather than hanging.
|
|
@@ -377,20 +394,22 @@ All four, with working code:
|
|
|
377
394
|
- **One seat can resume several devices.** Bind both `POINTER` and
|
|
378
395
|
`POINTER_ABSOLUTE` and GNOME gives you two, relative first. Taking
|
|
379
396
|
whichever resumes first is a coin flip — select on `device.capabilities`
|
|
380
|
-
instead. Sending an event the device lacks the capability for is
|
|
381
|
-
|
|
397
|
+
instead. Sending an event the device lacks the capability for is dropped with
|
|
398
|
+
no exception — libei only logs an error-level `Bug: ... device is not a
|
|
399
|
+
keyboard` line — which makes this look like the injection simply not working.
|
|
382
400
|
- **Read the accessor that matches the event type.** `event.key_event` on a
|
|
383
401
|
`POINTER_MOTION` event raises `TypeError` naming both types. libei itself
|
|
384
402
|
would have returned `KeyEvent(key=0, is_press=False)` — a real-looking
|
|
385
|
-
value —
|
|
386
|
-
`event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
|
|
403
|
+
value — and raised nothing, only logging a `Bug:` line at error level, so
|
|
404
|
+
branch on `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
|
|
387
405
|
carries no coordinates.
|
|
388
406
|
- **`GESTURES` and `STYLUS` are not in any released libei.** They match
|
|
389
407
|
upstream `main` and are here ready for it, but 1.6.0's capability enum
|
|
390
408
|
stops at `TEXT`. Binding them against a shipping library silently does
|
|
391
409
|
nothing — no error, no device, no events.
|
|
392
410
|
|
|
393
|
-
Nearly all of these fail
|
|
411
|
+
Nearly all of these fail without raising — some only log an error-level `Bug:`
|
|
412
|
+
line, some not even that — which is why
|
|
394
413
|
[docs/troubleshooting.md](docs/troubleshooting.md) is a checklist rather than
|
|
395
414
|
a list of error messages. Start there when nothing happens.
|
|
396
415
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# python-libei
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
3
4
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
4
5
|
[](https://pypi.org/project/python-libei/)
|
|
5
6
|
[](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
|
|
@@ -16,8 +17,11 @@ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
|
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
**The one concept:** events queue up, and **`frame()` is what sends them** as
|
|
19
|
-
one logical hardware event. Forget it and
|
|
20
|
-
warning, no movement.
|
|
20
|
+
one logical hardware event. Forget it and the motion is usually lost — no
|
|
21
|
+
exception, no warning, no movement. (Ending the chain with `stop_emulating()`
|
|
22
|
+
makes libei frame the queue for you, but it logs an error-level `Bug:` line
|
|
23
|
+
when it does; see [Things that will bite you](#things-that-will-bite-you).) Fill
|
|
24
|
+
the package, then post it.
|
|
21
25
|
|
|
22
26
|
New here? [docs/getting-started.md](docs/getting-started.md) is install
|
|
23
27
|
through a first real pointer motion, in five minutes.
|
|
@@ -59,8 +63,8 @@ than a widget-tree back door:
|
|
|
59
63
|
|
|
60
64
|
## Which API do I need?
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
to get permission, then `ei` to inject.
|
|
66
|
+
Four modules (`ei`, `eis`, `oeffis`, `portal`), and most callers need exactly
|
|
67
|
+
two of them: `oeffis` or `portal` to get permission, then `ei` to inject.
|
|
64
68
|
|
|
65
69
|
| You want to… | Use | Notes |
|
|
66
70
|
| --- | --- | --- |
|
|
@@ -68,6 +72,7 @@ to get permission, then `ei` to inject.
|
|
|
68
72
|
| **Consume input** from a compositor | `libei.ei` → `Receiver` | Compositor-side or input-capture code; same connection dance |
|
|
69
73
|
| **Get permission**, simply | `libei.oeffis` | One call, pollable fd, no dependencies. The consent dialog appears on **every** run |
|
|
70
74
|
| **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
|
|
75
|
+
| **Capture the user's real input** | `libei.portal` → `InputCaptureSession` | The read direction, through the InputCapture portal: once the compositor decides to, the user's pointer and keyboard are diverted to you over EIS. Exclusive while active, so hold it briefly. Only its negotiation half has been run against a real desktop — see [verification](docs/developers/verification.md) |
|
|
71
76
|
| **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
|
|
72
77
|
|
|
73
78
|
Each module has `is_available()`. `ei` and `eis` share the shapes around them:
|
|
@@ -106,8 +111,9 @@ because a skimming reader could otherwise take them as supported. 1.6.0's own
|
|
|
106
111
|
`enum ei_device_capability` stops at `TEXT`, and its `enum ei_event_type`
|
|
107
112
|
stops at `EI_EVENT_TEXT_UTF8` — so the swipe/pinch/hold/stylus members of
|
|
108
113
|
`EventType` cannot arrive either. The values here match upstream `main`
|
|
109
|
-
exactly, so they are ready for whatever release adds them. The
|
|
110
|
-
|
|
114
|
+
exactly, so they are ready for whatever release adds them. The gesture
|
|
115
|
+
and stylus functions `main` adds -- 46 in libei and 51 in libeis against
|
|
116
|
+
1.6.0's headers, counted 2026-09-30 -- are deliberately not bound:
|
|
111
117
|
nothing that ships today exports them, so nothing here could be verified
|
|
112
118
|
against a real library, which is the bar every other binding in this package
|
|
113
119
|
was held to.
|
|
@@ -129,7 +135,7 @@ and why, is in
|
|
|
129
135
|
|
|
130
136
|
## Status
|
|
131
137
|
|
|
132
|
-
Beta (`0.
|
|
138
|
+
Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
133
139
|
since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
|
|
134
140
|
|
|
135
141
|
The injection path is exercised end to end against the real libraries by the
|
|
@@ -170,9 +176,13 @@ Exactly what was run, when, and against which versions:
|
|
|
170
176
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
171
177
|
sending pointer, button, keyboard, scroll and touch input all use symbols
|
|
172
178
|
that have existed with a stable signature since 1.0.0, and upstream keeps
|
|
173
|
-
API/ABI back-compatible within the 1.x series.
|
|
174
|
-
|
|
175
|
-
the injection path passes the full suite with
|
|
179
|
+
API/ABI back-compatible within the 1.x series. The suite has been run
|
|
180
|
+
against 1.2.1 (Ubuntu 24.04, in CI) and 1.6.0 -- the latter on Fedora 44 and
|
|
181
|
+
45 and FreeBSD 15, where the injection path passes the full suite with
|
|
182
|
+
nothing skipped. Every binding's name, argument types and return type, and
|
|
183
|
+
every enum value, is also checked against the upstream headers of 1.0.0,
|
|
184
|
+
1.2.1, 1.4.0, 1.5.0 and 1.6.0 (`tests/test_abi.py`), which is the only
|
|
185
|
+
coverage 1.0.0, 1.4.0 and 1.5.0 have had.
|
|
176
186
|
|
|
177
187
|
Newer libei buys you more, per feature:
|
|
178
188
|
|
|
@@ -330,8 +340,12 @@ All four, with working code:
|
|
|
330
340
|
soon as the loop moves on, and using it afterwards raises `RuntimeError`.
|
|
331
341
|
Objects you pull *off* an event (`event.device`, `event.seat`) are safe to
|
|
332
342
|
keep — copy out `event.pointer_event` and friends rather than the event.
|
|
333
|
-
- **`frame()` or
|
|
334
|
-
them
|
|
343
|
+
- **`frame()` or the input is lost.** Events queue up until a `frame()` commits
|
|
344
|
+
them, and queued events that are never framed are dropped without an
|
|
345
|
+
exception or a log line. The one exception is a chain that ends in
|
|
346
|
+
`stop_emulating()`: libei frames what is queued, delivers it, and logs
|
|
347
|
+
`Bug: ei_device_stop_emulating: missing call to ei_device_frame()` at error
|
|
348
|
+
level. Measured against libei 1.2.1 and 1.6.0; don't rely on it.
|
|
335
349
|
- **`bind()` needs at least one capability.** Binding an empty set sends
|
|
336
350
|
nothing, so the device you are waiting for never arrives; this raises
|
|
337
351
|
`ValueError` rather than hanging.
|
|
@@ -340,20 +354,22 @@ All four, with working code:
|
|
|
340
354
|
- **One seat can resume several devices.** Bind both `POINTER` and
|
|
341
355
|
`POINTER_ABSOLUTE` and GNOME gives you two, relative first. Taking
|
|
342
356
|
whichever resumes first is a coin flip — select on `device.capabilities`
|
|
343
|
-
instead. Sending an event the device lacks the capability for is
|
|
344
|
-
|
|
357
|
+
instead. Sending an event the device lacks the capability for is dropped with
|
|
358
|
+
no exception — libei only logs an error-level `Bug: ... device is not a
|
|
359
|
+
keyboard` line — which makes this look like the injection simply not working.
|
|
345
360
|
- **Read the accessor that matches the event type.** `event.key_event` on a
|
|
346
361
|
`POINTER_MOTION` event raises `TypeError` naming both types. libei itself
|
|
347
362
|
would have returned `KeyEvent(key=0, is_press=False)` — a real-looking
|
|
348
|
-
value —
|
|
349
|
-
`event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
|
|
363
|
+
value — and raised nothing, only logging a `Bug:` line at error level, so
|
|
364
|
+
branch on `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
|
|
350
365
|
carries no coordinates.
|
|
351
366
|
- **`GESTURES` and `STYLUS` are not in any released libei.** They match
|
|
352
367
|
upstream `main` and are here ready for it, but 1.6.0's capability enum
|
|
353
368
|
stops at `TEXT`. Binding them against a shipping library silently does
|
|
354
369
|
nothing — no error, no device, no events.
|
|
355
370
|
|
|
356
|
-
Nearly all of these fail
|
|
371
|
+
Nearly all of these fail without raising — some only log an error-level `Bug:`
|
|
372
|
+
line, some not even that — which is why
|
|
357
373
|
[docs/troubleshooting.md](docs/troubleshooting.md) is a checklist rather than
|
|
358
374
|
a list of error messages. Start there when nothing happens.
|
|
359
375
|
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-libei"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Inject and
|
|
7
|
+
version = "0.7.0"
|
|
8
|
+
description = "Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -18,6 +18,8 @@ keywords = [
|
|
|
18
18
|
"input",
|
|
19
19
|
"input-emulation",
|
|
20
20
|
"emulated-input",
|
|
21
|
+
"input-capture",
|
|
22
|
+
"eis",
|
|
21
23
|
"portal",
|
|
22
24
|
"xdg-desktop-portal",
|
|
23
25
|
"remote-desktop",
|
|
@@ -31,6 +33,7 @@ classifiers = [
|
|
|
31
33
|
"Intended Audience :: Developers",
|
|
32
34
|
"Operating System :: POSIX :: BSD :: FreeBSD",
|
|
33
35
|
"Operating System :: POSIX :: Linux",
|
|
36
|
+
"Operating System :: POSIX",
|
|
34
37
|
"Programming Language :: Python :: 3",
|
|
35
38
|
"Programming Language :: Python :: 3 :: Only",
|
|
36
39
|
"Programming Language :: Python :: 3.10",
|
|
@@ -62,6 +65,8 @@ dev = ["pytest>=7", "ruff>=0.6", "mypy>=1.11"]
|
|
|
62
65
|
homepage = "https://github.com/ctrondlp/python-libei"
|
|
63
66
|
repository = "https://github.com/ctrondlp/python-libei"
|
|
64
67
|
issues = "https://github.com/ctrondlp/python-libei/issues"
|
|
68
|
+
documentation = "https://github.com/ctrondlp/python-libei/tree/main/docs"
|
|
69
|
+
pyguitest = "https://github.com/ctrondlp/pyguitest"
|
|
65
70
|
changelog = "https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md"
|
|
66
71
|
|
|
67
72
|
[tool.setuptools.packages.find]
|
|
@@ -128,7 +128,7 @@ event_scroll_get_discrete_dy = lib.function(
|
|
|
128
128
|
event_touch_get_id = lib.function("ei_event_touch_get_id", (c_void_p,), c_uint32)
|
|
129
129
|
event_touch_get_x = lib.function("ei_event_touch_get_x", (c_void_p,), c_double)
|
|
130
130
|
event_touch_get_y = lib.function("ei_event_touch_get_y", (c_void_p,), c_double)
|
|
131
|
-
event_touch_get_is_cancel = lib.function(
|
|
131
|
+
event_touch_get_is_cancel = lib.function( # libei 1.4+
|
|
132
132
|
"ei_event_touch_get_is_cancel", (c_void_p,), c_bool
|
|
133
133
|
)
|
|
134
134
|
event_text_get_utf8 = lib.function( # libei 1.6+
|
|
@@ -141,7 +141,9 @@ event_text_get_keysym_is_press = lib.function( # libei 1.6+
|
|
|
141
141
|
"ei_event_text_get_keysym_is_press", (c_void_p,), c_bool
|
|
142
142
|
)
|
|
143
143
|
# Borrowed reference -- the event still owns it, so wrap() rather than adopt().
|
|
144
|
-
event_pong_get_ping = lib.function(
|
|
144
|
+
event_pong_get_ping = lib.function( # libei 1.4+
|
|
145
|
+
"ei_event_pong_get_ping", (c_void_p,), c_void_p
|
|
146
|
+
)
|
|
145
147
|
|
|
146
148
|
device_ref = lib.function("ei_device_ref", (c_void_p,), c_void_p)
|
|
147
149
|
device_unref = lib.function("ei_device_unref", (c_void_p,), c_void_p)
|
|
@@ -236,7 +238,7 @@ touch_get_device = lib.function("ei_touch_get_device", (c_void_p,), c_void_p)
|
|
|
236
238
|
touch_down = lib.function("ei_touch_down", (c_void_p, c_double, c_double), None)
|
|
237
239
|
touch_motion = lib.function("ei_touch_motion", (c_void_p, c_double, c_double), None)
|
|
238
240
|
touch_up = lib.function("ei_touch_up", (c_void_p,), None)
|
|
239
|
-
touch_cancel = lib.function("ei_touch_cancel", (c_void_p,), None)
|
|
241
|
+
touch_cancel = lib.function("ei_touch_cancel", (c_void_p,), None) # libei 1.4+
|
|
240
242
|
|
|
241
243
|
# ei_new_ping() returns an owned reference; ei_ping() then triggers the round
|
|
242
244
|
# trip that comes back as an EI_EVENT_PONG.
|
|
@@ -104,7 +104,7 @@ event_scroll_get_discrete_dy = lib.function(
|
|
|
104
104
|
event_touch_get_id = lib.function("eis_event_touch_get_id", (c_void_p,), c_uint32)
|
|
105
105
|
event_touch_get_x = lib.function("eis_event_touch_get_x", (c_void_p,), c_double)
|
|
106
106
|
event_touch_get_y = lib.function("eis_event_touch_get_y", (c_void_p,), c_double)
|
|
107
|
-
event_touch_get_is_cancel = lib.function(
|
|
107
|
+
event_touch_get_is_cancel = lib.function( # libei 1.4+
|
|
108
108
|
"eis_event_touch_get_is_cancel", (c_void_p,), c_bool
|
|
109
109
|
)
|
|
110
110
|
event_text_get_utf8 = lib.function( # libei 1.6+
|
|
@@ -117,14 +117,16 @@ event_text_get_keysym_is_press = lib.function( # libei 1.6+
|
|
|
117
117
|
"eis_event_text_get_keysym_is_press", (c_void_p,), c_bool
|
|
118
118
|
)
|
|
119
119
|
# Borrowed reference -- the event still owns it, so wrap() rather than adopt().
|
|
120
|
-
event_pong_get_ping = lib.function(
|
|
120
|
+
event_pong_get_ping = lib.function( # libei 1.4+
|
|
121
|
+
"eis_event_pong_get_ping", (c_void_p,), c_void_p
|
|
122
|
+
)
|
|
121
123
|
|
|
122
124
|
client_ref = lib.function("eis_client_ref", (c_void_p,), c_void_p)
|
|
123
125
|
client_unref = lib.function("eis_client_unref", (c_void_p,), c_void_p)
|
|
124
126
|
client_is_sender = lib.function("eis_client_is_sender", (c_void_p,), c_bool)
|
|
125
127
|
# pid_t, i.e. a 32-bit signed int on Linux. Socket backend only, and
|
|
126
128
|
# negative errno on failure.
|
|
127
|
-
backend_socket_get_client_pid = lib.function(
|
|
129
|
+
backend_socket_get_client_pid = lib.function( # libei 1.5+
|
|
128
130
|
"eis_backend_socket_get_client_pid", (c_void_p,), c_int32
|
|
129
131
|
)
|
|
130
132
|
client_get_name = lib.function("eis_client_get_name", (c_void_p,), c_char_p)
|
|
@@ -277,7 +279,7 @@ touch_get_device = lib.function("eis_touch_get_device", (c_void_p,), c_void_p)
|
|
|
277
279
|
touch_down = lib.function("eis_touch_down", (c_void_p, c_double, c_double), None)
|
|
278
280
|
touch_motion = lib.function("eis_touch_motion", (c_void_p, c_double, c_double), None)
|
|
279
281
|
touch_up = lib.function("eis_touch_up", (c_void_p,), None)
|
|
280
|
-
touch_cancel = lib.function("eis_touch_cancel", (c_void_p,), None)
|
|
282
|
+
touch_cancel = lib.function("eis_touch_cancel", (c_void_p,), None) # libei 1.4+
|
|
281
283
|
|
|
282
284
|
ping = lib.function("eis_ping", (c_void_p,), None) # libei 1.4+
|
|
283
285
|
ping_get_id = lib.function("eis_ping_get_id", (c_void_p,), c_uint64) # libei 1.4+
|
|
@@ -30,6 +30,7 @@ class LazyLibrary:
|
|
|
30
30
|
self._lib: ctypes.CDLL | None = None
|
|
31
31
|
self._load_error: OSError | None = None
|
|
32
32
|
self._lock = threading.Lock()
|
|
33
|
+
self._declared: dict[str, tuple[tuple[type, ...], type | None]] = {}
|
|
33
34
|
|
|
34
35
|
def _ensure_loaded(self) -> ctypes.CDLL:
|
|
35
36
|
"""Return the opened library, opening it on first call.
|
|
@@ -75,6 +76,21 @@ class LazyLibrary:
|
|
|
75
76
|
return False
|
|
76
77
|
return True
|
|
77
78
|
|
|
79
|
+
@property
|
|
80
|
+
def soname(self) -> str:
|
|
81
|
+
"""The shared library this binds, as it is passed to ``dlopen``."""
|
|
82
|
+
return self._soname
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def declared(self) -> dict[str, tuple[tuple[type, ...], type | None]]:
|
|
86
|
+
"""Every function bound so far: C name -> (argtypes, restype).
|
|
87
|
+
|
|
88
|
+
Read by the ABI tests, which compare it with the library's exports and
|
|
89
|
+
with the upstream headers. Declaring a binding records it here and does
|
|
90
|
+
nothing else -- the library is still not opened until a call.
|
|
91
|
+
"""
|
|
92
|
+
return dict(self._declared)
|
|
93
|
+
|
|
78
94
|
def function(
|
|
79
95
|
self,
|
|
80
96
|
name: str,
|
|
@@ -94,6 +110,7 @@ class LazyLibrary:
|
|
|
94
110
|
# the dict is chosen only because "absent from the dict" already
|
|
95
111
|
# means "not resolved yet", with no None sentinel to confuse with a
|
|
96
112
|
# legitimately-None value.
|
|
113
|
+
self._declared[name] = (tuple(argtypes), restype)
|
|
97
114
|
cache: dict[str, Any] = {}
|
|
98
115
|
|
|
99
116
|
def call(*args: Any) -> Any:
|
|
@@ -113,7 +130,23 @@ class LazyLibrary:
|
|
|
113
130
|
bound.argtypes = list(argtypes)
|
|
114
131
|
bound.restype = restype
|
|
115
132
|
cache["f"] = bound
|
|
116
|
-
|
|
133
|
+
try:
|
|
134
|
+
return bound(*args)
|
|
135
|
+
except ctypes.ArgumentError:
|
|
136
|
+
# ctypes turns *any* exception raised while converting an
|
|
137
|
+
# argument into an ArgumentError carrying only its text -- no
|
|
138
|
+
# __cause__, no __context__. For a released object that loses
|
|
139
|
+
# the RuntimeError its ``_as_parameter_`` raised, so a caller
|
|
140
|
+
# catching RuntimeError (as documented) would miss it. Ask the
|
|
141
|
+
# arguments again and re-raise the real one.
|
|
142
|
+
for arg in args:
|
|
143
|
+
try:
|
|
144
|
+
arg._as_parameter_ # noqa: B018 - evaluated for its error
|
|
145
|
+
except RuntimeError as released:
|
|
146
|
+
raise released from None
|
|
147
|
+
except AttributeError:
|
|
148
|
+
continue
|
|
149
|
+
raise
|
|
117
150
|
|
|
118
151
|
call.__name__ = name
|
|
119
152
|
return call
|
|
@@ -185,6 +185,28 @@ class PortalDeniedError(PortalError):
|
|
|
185
185
|
self.message = message
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
class PortalSessionClosedError(PortalError):
|
|
189
|
+
"""The portal session ended while something was still waiting on it.
|
|
190
|
+
|
|
191
|
+
The ``org.freedesktop.portal.Session`` interface emits ``Closed`` when
|
|
192
|
+
the compositor or the portal ends a session from its side -- a
|
|
193
|
+
compositor restart, the portal process dying, a session revoked from a
|
|
194
|
+
settings panel. Distinct from :class:`PortalTimeoutError` (the session
|
|
195
|
+
is fine, the signal just has not come) and from
|
|
196
|
+
:class:`PortalDeniedError` (a request was answered, negatively): here
|
|
197
|
+
there is no session left to answer anything, so the wait that raised
|
|
198
|
+
this can never be satisfied by waiting longer.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, step: str) -> None:
|
|
202
|
+
"""Name what was being waited for when the session went away."""
|
|
203
|
+
super().__init__(
|
|
204
|
+
f"the portal session was closed while waiting for {step}; "
|
|
205
|
+
"the compositor or portal ended it from its side"
|
|
206
|
+
)
|
|
207
|
+
self.step = step
|
|
208
|
+
|
|
209
|
+
|
|
188
210
|
def _gio() -> tuple[Any, Any] | None:
|
|
189
211
|
"""Import Gio and GLib, or return None.
|
|
190
212
|
|
|
@@ -1038,6 +1060,27 @@ def _wait_for_signal(
|
|
|
1038
1060
|
loop = GLib.MainLoop()
|
|
1039
1061
|
result: dict[str, Any] = {}
|
|
1040
1062
|
timed_out = False
|
|
1063
|
+
session_closed = False
|
|
1064
|
+
|
|
1065
|
+
def on_closed(*_args: Any) -> None:
|
|
1066
|
+
"""End the wait: a closed session can never deliver the signal.
|
|
1067
|
+
|
|
1068
|
+
``Closed`` is emitted on the **session object's own path** (unlike
|
|
1069
|
+
``Activated``/``Deactivated``, which fire on the portal object --
|
|
1070
|
+
see above), so the subscription below uses ``session_handle`` as
|
|
1071
|
+
the path and needs no payload filtering: any ``Closed`` there is
|
|
1072
|
+
this session's. Without this subscription, a wait with
|
|
1073
|
+
``timeout=None`` on a session the compositor has ended -- a
|
|
1074
|
+
compositor restart, the portal dying, capture revoked from a
|
|
1075
|
+
settings panel -- blocked forever, indistinguishable from a signal
|
|
1076
|
+
that merely had not fired yet.
|
|
1077
|
+
"""
|
|
1078
|
+
nonlocal session_closed
|
|
1079
|
+
if result:
|
|
1080
|
+
return
|
|
1081
|
+
logger.debug("session %s closed while waiting for %s", session_handle, signal)
|
|
1082
|
+
session_closed = True
|
|
1083
|
+
loop.quit()
|
|
1041
1084
|
|
|
1042
1085
|
def on_signal(
|
|
1043
1086
|
_conn: Any,
|
|
@@ -1086,6 +1129,16 @@ def _wait_for_signal(
|
|
|
1086
1129
|
on_signal,
|
|
1087
1130
|
None,
|
|
1088
1131
|
)
|
|
1132
|
+
closed_subscription = connection.signal_subscribe(
|
|
1133
|
+
busname,
|
|
1134
|
+
_SESSION_INTERFACE,
|
|
1135
|
+
"Closed",
|
|
1136
|
+
session_handle,
|
|
1137
|
+
None,
|
|
1138
|
+
Gio.DBusSignalFlags.NONE,
|
|
1139
|
+
on_closed,
|
|
1140
|
+
None,
|
|
1141
|
+
)
|
|
1089
1142
|
logger.debug(
|
|
1090
1143
|
"waiting up to %s for %s.%s on %s for session %s",
|
|
1091
1144
|
"forever" if timeout is None else f"{timeout:g}s",
|
|
@@ -1095,7 +1148,7 @@ def _wait_for_signal(
|
|
|
1095
1148
|
session_handle,
|
|
1096
1149
|
)
|
|
1097
1150
|
try:
|
|
1098
|
-
if not result:
|
|
1151
|
+
if not result and not session_closed:
|
|
1099
1152
|
timeout_source = None
|
|
1100
1153
|
if timeout is not None:
|
|
1101
1154
|
timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
|
|
@@ -1115,6 +1168,9 @@ def _wait_for_signal(
|
|
|
1115
1168
|
GLib.source_remove(timeout_source)
|
|
1116
1169
|
finally:
|
|
1117
1170
|
connection.signal_unsubscribe(subscription)
|
|
1171
|
+
connection.signal_unsubscribe(closed_subscription)
|
|
1172
|
+
if session_closed:
|
|
1173
|
+
raise PortalSessionClosedError(signal)
|
|
1118
1174
|
if timed_out:
|
|
1119
1175
|
# timed_out is only ever set inside on_timeout, itself only ever
|
|
1120
1176
|
# registered when timeout is not None -- so this always holds, but
|
|
@@ -1176,12 +1232,13 @@ class InputCaptureSession:
|
|
|
1176
1232
|
about, and this cannot release on a caller's behalf during cleanup
|
|
1177
1233
|
without risking racing a capture that only just started.
|
|
1178
1234
|
|
|
1179
|
-
**
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
the
|
|
1183
|
-
|
|
1184
|
-
|
|
1235
|
+
**Only half live-tested.** The negotiation -- consent dialog, EIS fd,
|
|
1236
|
+
zones, ``Session.Close()`` -- was run against a real GNOME session on
|
|
1237
|
+
libei's own hand-verification terms (see
|
|
1238
|
+
``docs/developers/verification.md``); the *capture* half never has been,
|
|
1239
|
+
because verifying it means a developer accepting that their pointer will
|
|
1240
|
+
be diverted away from their own desktop for the length of the test -- not
|
|
1241
|
+
something to trigger without asking first, unlike everything else here. Designed
|
|
1185
1242
|
against ``/usr/share/dbus-1/interfaces/org.freedesktop.portal.
|
|
1186
1243
|
InputCapture.xml`` (the shipped portal spec, not the header alone) and
|
|
1187
1244
|
unit-tested against a fake connection reproducing that spec's documented
|