python-libei 0.4.0__py3-none-any.whl → 0.5.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/portal.py CHANGED
@@ -1,5 +1,14 @@
1
- """Negotiate an EIS connection by driving ``org.freedesktop.portal.RemoteDesktop``
2
- directly over D-Bus, rather than through :mod:`libei.oeffis`.
1
+ """Negotiate an EIS connection by driving a portal directly over D-Bus,
2
+ rather than through :mod:`libei.oeffis`.
3
+
4
+ Two portals, two directions. :class:`RemoteDesktopSession` negotiates
5
+ ``org.freedesktop.portal.RemoteDesktop`` to *inject* input; below it,
6
+ :class:`InputCaptureSession` negotiates the separate
7
+ ``org.freedesktop.portal.InputCapture`` to *receive* real input from the
8
+ user's own devices instead -- see its own class docstring, including why
9
+ it has never been run against a real portal. Everything in this module
10
+ docstring up to :class:`InputCaptureSession`'s own section is about
11
+ ``RemoteDesktopSession`` specifically.
3
12
 
4
13
  :mod:`libei.oeffis` wraps liboeffis, whose C API
5
14
  (``oeffis_create_session()``) takes only a device-type bitmask -- it exposes
@@ -65,7 +74,7 @@ import logging
65
74
  import os
66
75
  import time
67
76
  import uuid
68
- from typing import Any
77
+ from typing import Any, NamedTuple
69
78
 
70
79
  from .oeffis import DeviceType
71
80
 
@@ -79,16 +88,20 @@ __all__ = [
79
88
  "PortalDeniedError",
80
89
  "PortalTimeoutError",
81
90
  "RemoteDesktopSession",
91
+ "Activation",
92
+ "InputCaptureSession",
82
93
  "is_available",
83
94
  ]
84
95
 
85
96
  _BUS_NAME = "org.freedesktop.portal.Desktop"
86
97
  _OBJECT_PATH = "/org/freedesktop/portal/desktop"
87
98
  _REMOTE_DESKTOP = "org.freedesktop.portal.RemoteDesktop"
99
+ _INPUT_CAPTURE = "org.freedesktop.portal.InputCapture"
88
100
  _REQUEST_INTERFACE = "org.freedesktop.portal.Request"
89
101
  _SESSION_INTERFACE = "org.freedesktop.portal.Session"
90
102
 
91
103
  _MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
104
+ _MIN_INPUT_CAPTURE_VERSION = 2 # CreateSession2 is a v2-only method
92
105
 
93
106
  _DEFAULT_TIMEOUT = 60.0
94
107
  """Seconds to wait for one portal round trip. Generous, because a human has
@@ -351,6 +364,7 @@ def _request(
351
364
  leading_args: tuple[Any, ...],
352
365
  options: dict[str, Any],
353
366
  timeout: float,
367
+ trailing_args: tuple[Any, ...] = (),
354
368
  ) -> tuple[int, Any]:
355
369
  """Call a Request-returning portal method, racelessly.
356
370
 
@@ -374,6 +388,13 @@ def _request(
374
388
  Both legs share one deadline (see `_msec_until`), since a caller asking
375
389
  for 60 seconds means the answer arrives inside 60 seconds, not inside
376
390
  however many 60-second waits the sequence happens to be built from.
391
+
392
+ ``trailing_args``, appended after ``options`` in the call's parameter
393
+ tuple, exists for ``InputCapture.SetPointerBarriers`` -- the one
394
+ Request-returning method in either portal whose ``options`` is not its
395
+ last positional argument (``barriers`` and ``zone_set`` follow it).
396
+ Every RemoteDesktop call leaves this at its default, reproducing the
397
+ exact parameter tuple this function always built.
377
398
  """
378
399
  deadline = time.monotonic() + timeout
379
400
  unique_name = connection.get_unique_name()
@@ -428,7 +449,7 @@ def _request(
428
449
 
429
450
  subscribe(expected_path)
430
451
  try:
431
- parameters = GLib.Variant(signature, (*leading_args, options))
452
+ parameters = GLib.Variant(signature, (*leading_args, options, *trailing_args))
432
453
  reply = _call_sync(
433
454
  connection,
434
455
  Gio,
@@ -883,3 +904,610 @@ class RemoteDesktopSession:
883
904
  session_handle,
884
905
  busname,
885
906
  )
907
+
908
+
909
+ def _input_capture_version(
910
+ connection: Any, Gio: Any, GLib: Any, busname: str, timeout: float
911
+ ) -> int:
912
+ """Read the InputCapture portal's ``version`` property."""
913
+ reply = _call_sync(
914
+ connection,
915
+ Gio,
916
+ GLib,
917
+ busname,
918
+ _OBJECT_PATH,
919
+ "org.freedesktop.DBus.Properties",
920
+ "Get",
921
+ GLib.Variant("(ss)", (_INPUT_CAPTURE, "version")),
922
+ None,
923
+ int(timeout * 1000),
924
+ )
925
+ (version,) = reply.unpack()
926
+ return int(version)
927
+
928
+
929
+ def _wait_for_signal(
930
+ connection: Any,
931
+ Gio: Any,
932
+ GLib: Any,
933
+ busname: str,
934
+ interface: str,
935
+ signal: str,
936
+ path: str,
937
+ timeout: float | None,
938
+ ) -> tuple[Any, ...]:
939
+ """Block for one emission of ``signal`` on ``path``, unpacked.
940
+
941
+ Unlike `_request`, nothing here *triggers* the signal: ``Activated`` and
942
+ ``Deactivated`` fire whenever the compositor decides a pointer barrier
943
+ was crossed, which the caller has no control over and which may happen
944
+ before this is even called (a long-enabled session, subscribed to
945
+ late) or not for a long time. ``timeout=None`` waits indefinitely --
946
+ the read a caller wants when there is nothing else useful to do but
947
+ wait for a human to move the pointer.
948
+ """
949
+ loop = GLib.MainLoop()
950
+ result: dict[str, Any] = {}
951
+ timed_out = False
952
+
953
+ def on_signal(
954
+ _conn: Any,
955
+ _sender: Any,
956
+ _path: Any,
957
+ _iface: Any,
958
+ _signal: Any,
959
+ params: Any,
960
+ *_a: Any,
961
+ ) -> None:
962
+ if result: # a subscription that outlives its own wait can fire twice
963
+ return
964
+ result["args"] = params.unpack()
965
+ loop.quit()
966
+
967
+ def on_timeout() -> bool:
968
+ nonlocal timed_out
969
+ timed_out = True
970
+ loop.quit()
971
+ return False
972
+
973
+ subscription = connection.signal_subscribe(
974
+ busname,
975
+ interface,
976
+ signal,
977
+ path,
978
+ None,
979
+ Gio.DBusSignalFlags.NONE,
980
+ on_signal,
981
+ None,
982
+ )
983
+ try:
984
+ if not result:
985
+ timeout_source = None
986
+ if timeout is not None:
987
+ timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
988
+ try:
989
+ loop.run()
990
+ finally:
991
+ if timeout_source is not None:
992
+ GLib.source_remove(timeout_source)
993
+ finally:
994
+ connection.signal_unsubscribe(subscription)
995
+ if timed_out:
996
+ # timed_out is only ever set inside on_timeout, itself only ever
997
+ # registered when timeout is not None -- so this always holds, but
998
+ # not in a shape mypy can see across the closure.
999
+ assert timeout is not None
1000
+ raise PortalTimeoutError(signal, timeout)
1001
+ return result["args"]
1002
+
1003
+
1004
+ class Activation(NamedTuple):
1005
+ """One ``Activated`` signal's payload -- see
1006
+ :meth:`InputCaptureSession.wait_for_activation`.
1007
+ """
1008
+
1009
+ activation_id: int
1010
+ """Pass this back to :meth:`InputCaptureSession.release`. Wraps around;
1011
+ do not assume it only increases within one process's lifetime."""
1012
+
1013
+ cursor_position: tuple[float, float] | None
1014
+ """Where the pointer was, in the coordinate space :meth:`
1015
+ InputCaptureSession.zones` reports -- usually *outside* every zone,
1016
+ since a barrier sits on a zone's own edge. None if the compositor sent
1017
+ none, which the spec permits."""
1018
+
1019
+ barrier_id: int | None
1020
+ """The barrier that triggered, matching one passed to
1021
+ :meth:`InputCaptureSession.set_pointer_barriers` -- 0 if the compositor
1022
+ could not determine which, None if capture was not triggered by a
1023
+ barrier at all."""
1024
+
1025
+
1026
+ class InputCaptureSession:
1027
+ """A negotiated ``org.freedesktop.portal.InputCapture`` session.
1028
+
1029
+ The read half of what :class:`RemoteDesktopSession` is for the write
1030
+ direction: instead of injecting synthetic input, this receives real
1031
+ input from the user's own devices once the compositor decides to divert
1032
+ it here. That decision is the whole point of the protocol and is never
1033
+ this session's to make -- see :meth:`enable` and :meth:`
1034
+ wait_for_activation`.
1035
+
1036
+ **Capturing is exclusive.** Once the compositor activates a capture,
1037
+ the events it captures stop reaching the desktop entirely and are sent
1038
+ only to this session over the EIS connection -- there is no
1039
+ "observe without diverting" mode. A caller holding this open across
1040
+ more than the moment it needs is holding the user's pointer or keyboard
1041
+ hostage from their own desktop; keep the enabled window as short as
1042
+ the caller can manage, and call :meth:`release` the instant the answer
1043
+ needed has been read.
1044
+
1045
+ The same two things :class:`RemoteDesktopSession` has to release do not
1046
+ release themselves here either -- the portal session outlives this
1047
+ object, and the EIS fd is owned once read. :meth:`close` (or the
1048
+ context-manager form) does both. Unlike :class:`RemoteDesktopSession`,
1049
+ an *active* capture must additionally be handed back explicitly with
1050
+ :meth:`release` before :meth:`close` -- closing the session without it
1051
+ is exactly the failure mode the exclusivity paragraph above warns
1052
+ about, and this cannot release on a caller's behalf during cleanup
1053
+ without risking racing a capture that only just started.
1054
+
1055
+ **Never live-tested.** Every other class in this module that talks to a
1056
+ real portal carries a hand-verification note in its own docstring; this
1057
+ one does not, because verifying it means a human clicking through the
1058
+ consent dialog *and* accepting that their pointer will be diverted away
1059
+ from their own desktop for the length of the test -- not something to
1060
+ trigger without asking first, unlike everything else here. Designed
1061
+ against ``/usr/share/dbus-1/interfaces/org.freedesktop.portal.
1062
+ InputCapture.xml`` (the shipped portal spec, not the header alone) and
1063
+ unit-tested against a fake connection reproducing that spec's documented
1064
+ shapes; see ``tests/test_inputcapture.py``'s own module docstring for
1065
+ what that does and does not prove.
1066
+ """
1067
+
1068
+ def __init__(
1069
+ self,
1070
+ connection: Any,
1071
+ session_handle: str,
1072
+ eis_fd: int,
1073
+ restore_token: str | None,
1074
+ busname: str = _BUS_NAME,
1075
+ ) -> None:
1076
+ self._connection = connection
1077
+ self._session_handle: str | None = session_handle
1078
+ self._eis_fd: int | None = eis_fd
1079
+ self._eis_fd_claimed = False
1080
+ self._busname = busname
1081
+ self._closed = False
1082
+ self.restore_token = restore_token
1083
+ """The token to pass as ``restore_token=`` on the next call to
1084
+ avoid re-prompting, or ``None`` -- see
1085
+ `RemoteDesktopSession.restore_token`, which this mirrors exactly."""
1086
+
1087
+ @property
1088
+ def session_handle(self) -> str:
1089
+ """The object path this session is addressed by.
1090
+
1091
+ Exposed (unlike `RemoteDesktopSession`, which has no reason to)
1092
+ because :meth:`wait_for_activation` and :meth:`wait_for_deactivation`
1093
+ are scoped to one session's own signals, and a caller building
1094
+ something this module does not -- watching several sessions on one
1095
+ `GLib.MainContext`, say -- needs it to tell them apart.
1096
+ """
1097
+ if self._session_handle is None:
1098
+ raise PortalError("the session is closed")
1099
+ return self._session_handle
1100
+
1101
+ @property
1102
+ def eis_fd(self) -> int:
1103
+ """The fd to pass to a passive ``libei.ei.Receiver`` context.
1104
+
1105
+ Reading this transfers ownership to the caller, exactly as
1106
+ `RemoteDesktopSession.eis_fd` does for the sender side -- see that
1107
+ property's docstring for the ownership rule this mirrors.
1108
+ """
1109
+ if self._eis_fd is None:
1110
+ raise PortalError("the session is closed; its EIS fd is gone")
1111
+ self._eis_fd_claimed = True
1112
+ return self._eis_fd
1113
+
1114
+ def _plain_call(self, method: str, *, timeout: float = _DEFAULT_TIMEOUT) -> None:
1115
+ """Call ``Enable`` or ``Disable``: no options, no reply, no Request.
1116
+
1117
+ Both take effect (or fail on the D-Bus itself) synchronously, with
1118
+ no consent dialog and so no ``Response`` signal to wait for --
1119
+ unlike ``Start``, ``GetZones`` and ``SetPointerBarriers``, which go
1120
+ through `_request`. ``Release`` is this same call shape but needs
1121
+ an options vardict of its own, so it is not built on this.
1122
+ """
1123
+ gio_modules = _gio()
1124
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1125
+ raise PortalError("PyGObject is not installed")
1126
+ Gio, GLib = gio_modules
1127
+ _call_sync(
1128
+ self._connection,
1129
+ Gio,
1130
+ GLib,
1131
+ self._busname,
1132
+ _OBJECT_PATH,
1133
+ _INPUT_CAPTURE,
1134
+ method,
1135
+ GLib.Variant("(oa{sv})", (self.session_handle, {})),
1136
+ None,
1137
+ int(timeout * 1000),
1138
+ )
1139
+
1140
+ def enable(self, timeout: float = _DEFAULT_TIMEOUT) -> None:
1141
+ """Allow capture to be triggered from now on.
1142
+
1143
+ Does not itself divert any input -- it only arms whatever pointer
1144
+ barriers :meth:`set_pointer_barriers` set up. The compositor decides
1145
+ if and when a barrier is actually crossed; :meth:`wait_for_activation`
1146
+ is how a caller finds out that it was.
1147
+ """
1148
+ self._plain_call("Enable", timeout=timeout)
1149
+
1150
+ def disable(self, timeout: float = _DEFAULT_TIMEOUT) -> None:
1151
+ """Prevent capture from being triggered again until :meth:`enable`.
1152
+
1153
+ Does not end a capture already in progress -- see :meth:`release`
1154
+ for that -- and, per the portal spec, emits no signal of its own
1155
+ even though it can leave a `Deactivated` still in flight for a
1156
+ capture that was already active when this was called.
1157
+ """
1158
+ self._plain_call("Disable", timeout=timeout)
1159
+
1160
+ def release(
1161
+ self,
1162
+ activation_id: int,
1163
+ cursor_position: tuple[float, float] | None = None,
1164
+ timeout: float = _DEFAULT_TIMEOUT,
1165
+ ) -> None:
1166
+ """Hand an active capture back to the desktop.
1167
+
1168
+ Call this as soon as whatever the capture was opened to read has
1169
+ been read -- see the exclusivity paragraph on the class docstring.
1170
+ ``cursor_position`` is only ever a suggestion to the compositor for
1171
+ where to place the pointer on hand-back, in the coordinate space
1172
+ :meth:`zones` reports; omitted, the compositor decides on its own.
1173
+ """
1174
+ gio_modules = _gio()
1175
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1176
+ raise PortalError("PyGObject is not installed")
1177
+ Gio, GLib = gio_modules
1178
+ options: dict[str, Any] = {"activation_id": GLib.Variant("u", activation_id)}
1179
+ if cursor_position is not None:
1180
+ options["cursor_position"] = GLib.Variant("(dd)", cursor_position)
1181
+ _call_sync(
1182
+ self._connection,
1183
+ Gio,
1184
+ GLib,
1185
+ self._busname,
1186
+ _OBJECT_PATH,
1187
+ _INPUT_CAPTURE,
1188
+ "Release",
1189
+ GLib.Variant("(oa{sv})", (self.session_handle, options)),
1190
+ None,
1191
+ int(timeout * 1000),
1192
+ )
1193
+
1194
+ def zones(
1195
+ self, timeout: float = _DEFAULT_TIMEOUT
1196
+ ) -> tuple[int, list[tuple[int, int, int, int]]]:
1197
+ """The session's current input zones, as ``(zone_set, zones)``.
1198
+
1199
+ Each zone is ``(width, height, x, y)``, that exact order -- the
1200
+ wire order the spec documents, not the ``(x, y, width, height)``
1201
+ order `pyguitest.Screen` uses; a caller bridging the two must
1202
+ reorder, not assume they match. ``zone_set`` must be passed back to
1203
+ :meth:`set_pointer_barriers` unchanged, or the call fails: the
1204
+ portal uses it to detect a caller acting on a stale zone layout
1205
+ (a monitor unplugged since the last call, say).
1206
+ """
1207
+ gio_modules = _gio()
1208
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1209
+ raise PortalError("PyGObject is not installed")
1210
+ Gio, GLib = gio_modules
1211
+ code, results = _request(
1212
+ self._connection,
1213
+ Gio,
1214
+ GLib,
1215
+ self._busname,
1216
+ _INPUT_CAPTURE,
1217
+ "GetZones",
1218
+ "(oa{sv})",
1219
+ (self.session_handle,),
1220
+ {},
1221
+ timeout,
1222
+ )
1223
+ if code != 0:
1224
+ raise PortalDeniedError("GetZones")
1225
+ zone_set = results.get("zone_set", 0)
1226
+ zones = [tuple(z) for z in results.get("zones", [])]
1227
+ return int(zone_set), zones
1228
+
1229
+ def set_pointer_barriers(
1230
+ self,
1231
+ barriers: list[tuple[int, int, int, int, int]],
1232
+ zone_set: int,
1233
+ timeout: float = _DEFAULT_TIMEOUT,
1234
+ ) -> list[int]:
1235
+ """Arm pointer barriers; returns the subset the compositor refused.
1236
+
1237
+ Each barrier is ``(barrier_id, x1, y1, x2, y2)`` -- a non-zero id
1238
+ the caller chooses (it comes back on the `Activated` signal that
1239
+ the barrier triggered), then the line's endpoints, which must be
1240
+ purely horizontal (``y1 == y2``) or purely vertical (``x1 == x2``)
1241
+ and must sit on the outside edge of the zone union -- see the
1242
+ portal spec for the exact placement rules; this does not validate
1243
+ them, the compositor does, at this call.
1244
+
1245
+ **Calling this suspends the session.** The spec is explicit: after
1246
+ this call the caller must call :meth:`enable` again, even if
1247
+ capture was already enabled before. Passing an empty list clears
1248
+ every barrier already set.
1249
+ """
1250
+ gio_modules = _gio()
1251
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1252
+ raise PortalError("PyGObject is not installed")
1253
+ Gio, GLib = gio_modules
1254
+ packed = [
1255
+ {
1256
+ "barrier_id": GLib.Variant("u", barrier_id),
1257
+ "position": GLib.Variant("(iiii)", (x1, y1, x2, y2)),
1258
+ }
1259
+ for barrier_id, x1, y1, x2, y2 in barriers
1260
+ ]
1261
+ code, results = _request(
1262
+ self._connection,
1263
+ Gio,
1264
+ GLib,
1265
+ self._busname,
1266
+ _INPUT_CAPTURE,
1267
+ "SetPointerBarriers",
1268
+ "(oa{sv}aa{sv}u)",
1269
+ (self.session_handle,),
1270
+ {},
1271
+ timeout,
1272
+ trailing_args=(packed, zone_set),
1273
+ )
1274
+ if code != 0:
1275
+ raise PortalDeniedError("SetPointerBarriers")
1276
+ return list(results.get("failed_barriers", []))
1277
+
1278
+ def wait_for_activation(self, timeout: float | None = None) -> Activation:
1279
+ """Block until the compositor activates capture, or ``timeout``.
1280
+
1281
+ Only returns once a real barrier crossing has been reported --
1282
+ which, on hardware, means a human moved a physical pointer across
1283
+ one. There is no way to trigger this synthetically (see the class
1284
+ docstring's third paragraph), so this call can legitimately hang
1285
+ until someone does that, and `timeout=None` -- the default -- waits
1286
+ for exactly as long as that takes. Pass a real number for any
1287
+ caller that would rather fail than sit there.
1288
+ """
1289
+ gio_modules = _gio()
1290
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1291
+ raise PortalError("PyGObject is not installed")
1292
+ Gio, GLib = gio_modules
1293
+ _session_handle, options = _wait_for_signal(
1294
+ self._connection,
1295
+ Gio,
1296
+ GLib,
1297
+ self._busname,
1298
+ _INPUT_CAPTURE,
1299
+ "Activated",
1300
+ self.session_handle,
1301
+ timeout,
1302
+ )
1303
+ cursor = options.get("cursor_position")
1304
+ return Activation(
1305
+ activation_id=int(options.get("activation_id", 0)),
1306
+ cursor_position=tuple(cursor) if cursor is not None else None,
1307
+ barrier_id=options.get("barrier_id"),
1308
+ )
1309
+
1310
+ def wait_for_deactivation(self, timeout: float | None = None) -> int:
1311
+ """Block until the current capture ends, returning its activation_id."""
1312
+ gio_modules = _gio()
1313
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1314
+ raise PortalError("PyGObject is not installed")
1315
+ Gio, GLib = gio_modules
1316
+ _session_handle, options = _wait_for_signal(
1317
+ self._connection,
1318
+ Gio,
1319
+ GLib,
1320
+ self._busname,
1321
+ _INPUT_CAPTURE,
1322
+ "Deactivated",
1323
+ self.session_handle,
1324
+ timeout,
1325
+ )
1326
+ return int(options.get("activation_id", 0))
1327
+
1328
+ def close(self) -> None:
1329
+ """End the portal session, and close the EIS fd if unclaimed.
1330
+
1331
+ Idempotent, and deliberately does not call :meth:`release` first --
1332
+ see the class docstring for why an *active* capture must be
1333
+ released explicitly before this, not folded into cleanup here.
1334
+ """
1335
+ if self._closed:
1336
+ return
1337
+ self._closed = True
1338
+ if self._eis_fd is not None and not self._eis_fd_claimed:
1339
+ try:
1340
+ os.close(self._eis_fd)
1341
+ except OSError as exc:
1342
+ logger.debug("closing the EIS fd failed: %s", exc)
1343
+ self._eis_fd = None
1344
+ if self._session_handle is None or self._connection is None:
1345
+ return
1346
+ gio_modules = _gio()
1347
+ if gio_modules is None: # pragma: no cover - unreachable once negotiated
1348
+ return
1349
+ Gio, GLib = gio_modules
1350
+ try:
1351
+ _close_session(
1352
+ self._connection,
1353
+ Gio,
1354
+ GLib,
1355
+ self._busname,
1356
+ self._session_handle,
1357
+ )
1358
+ finally:
1359
+ self._session_handle = None
1360
+ self._connection = None
1361
+
1362
+ def __enter__(self) -> InputCaptureSession:
1363
+ return self
1364
+
1365
+ def __exit__(self, *_exc: Any) -> None:
1366
+ self.close()
1367
+
1368
+ def __del__(self) -> None:
1369
+ # See RemoteDesktopSession.__del__ for why this closes only the fd.
1370
+ if getattr(self, "_eis_fd_claimed", True):
1371
+ return
1372
+ eis_fd = getattr(self, "_eis_fd", None)
1373
+ if eis_fd is not None:
1374
+ try:
1375
+ os.close(eis_fd)
1376
+ except OSError:
1377
+ pass
1378
+
1379
+ @classmethod
1380
+ def negotiate(
1381
+ cls,
1382
+ *,
1383
+ capabilities: DeviceType = DeviceType.ALL_DEVICES,
1384
+ connection: Any = None,
1385
+ persist_mode: PersistMode = PersistMode.NONE,
1386
+ restore_token: str | None = None,
1387
+ busname: str = _BUS_NAME,
1388
+ timeout: float = _DEFAULT_TIMEOUT,
1389
+ ) -> InputCaptureSession:
1390
+ """Negotiate an InputCapture portal session and connect it to EIS.
1391
+
1392
+ Blocks until ``CreateSession2`` -> ``Start`` -> ``ConnectToEIS``
1393
+ resolves, prompting the user for consent along the way unless
1394
+ ``restore_token`` lets the portal skip that. Raises
1395
+ :class:`PortalVersionError` if the compositor's InputCapture portal
1396
+ predates ``CreateSession2`` (needs v2+ -- this module never speaks
1397
+ the deprecated v1 ``CreateSession``), :class:`PortalDeniedError` if
1398
+ ``Start`` is declined, and :class:`PortalTimeoutError` if any one
1399
+ round trip exceeds ``timeout`` seconds.
1400
+
1401
+ Returns before anything is actually captured: :meth:`set_pointer_barriers`
1402
+ and :meth:`enable` still have to be called, and even then nothing
1403
+ happens until the compositor decides a barrier was crossed -- see
1404
+ :meth:`wait_for_activation`. Nothing about negotiating this session
1405
+ diverts input on its own.
1406
+
1407
+ ``capabilities`` reuses :class:`DeviceType` -- the InputCapture
1408
+ portal's own bitmask documents the identical three bits
1409
+ (``KEYBOARD``, ``POINTER``, ``TOUCHSCREEN``) for the same purpose,
1410
+ selecting which device classes this session may ever capture.
1411
+ ``persist_mode`` and ``restore_token`` work exactly as they do for
1412
+ :meth:`RemoteDesktopSession.negotiate` -- see that method.
1413
+ """
1414
+ if restore_token is not None and persist_mode == PersistMode.NONE:
1415
+ raise ValueError(
1416
+ "restore_token was given with persist_mode=NONE: the portal "
1417
+ "consumes a restore token on use and only issues a new one "
1418
+ "when persistence is requested, so this would spend the "
1419
+ "saved token and hand back None. Pass a persist_mode too."
1420
+ )
1421
+
1422
+ gio_modules = _gio()
1423
+ if gio_modules is None:
1424
+ raise PortalError(
1425
+ "PyGObject is not installed; libei.portal needs it to "
1426
+ "negotiate an InputCapture portal session "
1427
+ "(pip install 'python-libei[portal]')"
1428
+ )
1429
+ Gio, GLib = gio_modules
1430
+
1431
+ if connection is None:
1432
+ try:
1433
+ connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
1434
+ except _glib_error(GLib) as exc:
1435
+ raise PortalError(f"cannot reach the session bus: {exc}") from exc
1436
+
1437
+ version = _input_capture_version(connection, Gio, GLib, busname, timeout)
1438
+ if version < _MIN_INPUT_CAPTURE_VERSION:
1439
+ raise PortalVersionError(
1440
+ f"InputCapture version {version} is too old for "
1441
+ f"CreateSession2 (need {_MIN_INPUT_CAPTURE_VERSION}+)"
1442
+ )
1443
+
1444
+ if capabilities == DeviceType.ALL_DEVICES:
1445
+ types = _ALL_DEVICE_TYPES
1446
+ else:
1447
+ types = capabilities
1448
+ reply = _call_sync(
1449
+ connection,
1450
+ Gio,
1451
+ GLib,
1452
+ busname,
1453
+ _OBJECT_PATH,
1454
+ _INPUT_CAPTURE,
1455
+ "CreateSession2",
1456
+ GLib.Variant(
1457
+ "(a{sv})",
1458
+ ({"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},),
1459
+ ),
1460
+ None,
1461
+ int(timeout * 1000),
1462
+ )
1463
+ (results,) = reply.unpack()
1464
+ session_handle = results.get("session_handle")
1465
+ if not isinstance(session_handle, str):
1466
+ raise PortalError("CreateSession2 returned no session_handle")
1467
+
1468
+ # Past this point a session exists inside xdg-desktop-portal, and
1469
+ # nothing else can close it -- see RemoteDesktopSession.negotiate's
1470
+ # identical reasoning, which this mirrors line for line.
1471
+ try:
1472
+ options: dict[str, Any] = {"capabilities": GLib.Variant("u", int(types))}
1473
+ if persist_mode != PersistMode.NONE:
1474
+ options["persist_mode"] = GLib.Variant("u", int(persist_mode))
1475
+ if restore_token is not None:
1476
+ options["restore_token"] = GLib.Variant("s", restore_token)
1477
+ code, start_results = _request(
1478
+ connection,
1479
+ Gio,
1480
+ GLib,
1481
+ busname,
1482
+ _INPUT_CAPTURE,
1483
+ "Start",
1484
+ "(osa{sv})",
1485
+ (session_handle, ""),
1486
+ options,
1487
+ timeout,
1488
+ )
1489
+ if code != 0:
1490
+ raise PortalDeniedError(
1491
+ "Start", "the user declined the input-capture consent dialog"
1492
+ )
1493
+ new_restore_token = start_results.get("restore_token")
1494
+
1495
+ eis_fd = _call_for_fd(
1496
+ connection,
1497
+ Gio,
1498
+ GLib,
1499
+ busname,
1500
+ _INPUT_CAPTURE,
1501
+ "ConnectToEIS",
1502
+ session_handle,
1503
+ timeout,
1504
+ )
1505
+ except BaseException:
1506
+ # BaseException, not Exception: Start blocks on a human
1507
+ # answering a consent dialog, so Ctrl-C during that wait is a
1508
+ # routine way out of this function -- and it strands an
1509
+ # approved session exactly as a decline does.
1510
+ _close_session(connection, Gio, GLib, busname, session_handle)
1511
+ raise
1512
+
1513
+ return cls(connection, session_handle, eis_fd, new_restore_token, busname)