fix(injection): give every RemoteDesktop portal request a unique handle_token (#996)

* fix(injection): give every RemoteDesktop portal request a unique handle_token

Subscribe to predicted Request paths before calling the portal and retain the RemoteDesktop portal in Flatpak when ydotoold is unavailable.

Fixes #993

* refactor(injection): share the portal close in _demote_portal_backend

* fix(injection): recreate the portal session and keep partial counts under Flatpak
This commit is contained in:
Jatin K Malik
2026-10-21 00:02:28 -07:00
committed by GitHub
parent 566a228858
commit 5fa0db6a47
3 changed files with 538 additions and 86 deletions
@@ -12,7 +12,8 @@ The D-Bus conversation is asynchronous: each request method returns a
``org.freedesktop.portal.Request`` object that later emits ``Response``. To
keep that deterministic this client runs a dedicated worker thread holding a
private bus connection whose signals dispatch on a private
``GLib.MainContext``; callers submit jobs and block on the result.
``GLib.MainContext``; each request has a unique handle token and is subscribed
before the method call; callers submit jobs and block on the result.
Portal versions: version 2 of the interface adds ``persist_mode`` and the
``restore_token`` option, which lets a later session resume without the
@@ -23,6 +24,7 @@ re-authorized on each app start.
import logging
import os
import queue
import secrets
import threading
import time
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
@@ -192,6 +194,17 @@ def _save_restore_token(token: str) -> None:
logger.debug(f"Could not persist RemoteDesktop restore token: {e}")
def _new_token() -> str:
"""Return a unique D-Bus handle token."""
return f"vocalinux_{secrets.token_hex(8)}"
def _predicted_request_path(unique_name: str, token: str) -> str:
"""Return the Request object path implied by a connection name and token."""
sender = unique_name.lstrip(":").replace(".", "_")
return f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
class RemoteDesktopPortal:
"""Synchronous client for ``org.freedesktop.portal.RemoteDesktop``.
@@ -277,6 +290,17 @@ class RemoteDesktopPortal:
with self._lock:
self._submit(lambda: self._shortcut(steps), timeout=_SUBMIT_TIMEOUT_S)
def reset_session(self) -> None:
"""Close the current portal session without stopping the client."""
with self._lock:
session_path = self._session_path
self._session_path = None
if session_path and self._conn is not None:
try:
self._submit(lambda: self._close_session(session_path), timeout=5)
except Exception as e:
logger.debug(f"Could not close RemoteDesktop session: {e}")
def close(self) -> None:
"""Close the portal session and stop the worker thread."""
with self._lock:
@@ -461,14 +485,8 @@ class RemoteDesktopPortal:
)
return int(result.get_child_value(0).get_variant().get_uint32())
def _await_response(self, request_path: str, timeout_s: float) -> Dict[str, Any]:
"""Wait for the Request::Response signal on ``request_path``.
Signals dispatch on this thread's private main context; iterating it
here is what delivers the callback, so there is no race between the
method returning the request path and the subscription being added.
"""
outcome: Dict[str, Any] = {}
def _subscribe_response(self, request_path: str, outcome: Dict[str, Any]) -> int:
"""Subscribe to Request::Response signals for ``request_path``."""
def _on_response(
_conn: Any,
@@ -481,31 +499,50 @@ class RemoteDesktopPortal:
outcome["response"] = params.get_child_value(0).get_uint32()
outcome["results"] = params.get_child_value(1)
subscription = self._conn.signal_subscribe(
_DESKTOP_BUS_NAME,
_REQUEST_IFACE,
"Response",
request_path,
None,
Gio.DBusSignalFlags.NONE,
_on_response,
return int(
self._conn.signal_subscribe(
_DESKTOP_BUS_NAME,
_REQUEST_IFACE,
"Response",
request_path,
None,
Gio.DBusSignalFlags.NONE,
_on_response,
)
)
try:
deadline = time.monotonic() + timeout_s
if self._job_deadline is not None:
deadline = min(deadline, self._job_deadline)
while "response" not in outcome:
self._check_job_aborted()
if time.monotonic() >= deadline:
raise RemoteDesktopPortalError(
f"Portal request timed out after {timeout_s:.0f}s ({request_path})"
)
self._context.iteration(False)
time.sleep(_RESPONSE_POLL_INTERVAL_S)
finally:
self._conn.signal_unsubscribe(subscription)
def _await_response(
self,
method: str,
request_path: str,
outcome: Dict[str, Any],
timeout_s: float,
) -> Dict[str, Any]:
"""Wait for the subscribed Request::Response signal on ``request_path``."""
started = time.monotonic()
deadline = started + timeout_s
if self._job_deadline is not None:
deadline = min(deadline, self._job_deadline)
next_wait_log = started + 5.0
while "response" not in outcome:
self._check_job_aborted()
now = time.monotonic()
if now >= deadline:
raise RemoteDesktopPortalError(
f"Portal request timed out after {timeout_s:.0f}s ({request_path})"
)
if now >= next_wait_log:
logger.debug(
"still waiting for %s Response on %s (%.0fs elapsed)",
method,
request_path,
now - started,
)
next_wait_log += 5.0
self._context.iteration(False)
time.sleep(_RESPONSE_POLL_INTERVAL_S)
response = outcome["response"]
logger.debug("Received %s Response code %s on %s", method, response, request_path)
if response == _RESPONSE_OK:
return outcome
if response == _RESPONSE_CANCELLED:
@@ -515,7 +552,9 @@ class RemoteDesktopPortal:
def _request(
self,
method: str,
params: Any,
signature: str,
args: Tuple[Any, ...],
options: Dict[str, Any],
timeout_s: float = _REQUEST_TIMEOUT_S,
) -> Any:
"""Issue a portal request method and return its Response results.
@@ -527,15 +566,35 @@ class RemoteDesktopPortal:
budget = self._job_time_left(timeout_s)
if budget <= 0:
raise RemoteDesktopPortalError(f"Portal request {method} exceeded the deadline")
result = self._call_sync(
_REMOTE_DESKTOP_IFACE,
method,
params,
"(o)",
timeout_ms=int(max(1.0, min(_REQUEST_TIMEOUT_S, budget)) * 1000),
)
request_path = result.get_child_value(0).get_string()
outcome = self._await_response(request_path, budget)
request_options = dict(options)
token = _new_token()
request_options["handle_token"] = token
predicted_path = _predicted_request_path(self._conn.get_unique_name(), token)
outcome: Dict[str, Any] = {}
subscriptions: List[int] = []
subscriptions.append(self._subscribe_response(predicted_path, outcome))
logger.debug("Issuing %s request on %s", method, predicted_path)
try:
result = self._call_sync(
_REMOTE_DESKTOP_IFACE,
method,
GLib.Variant(signature, (*args, self._sv_options(request_options))),
"(o)",
timeout_ms=int(max(1.0, min(_REQUEST_TIMEOUT_S, budget)) * 1000),
)
request_path = result.get_child_value(0).get_string()
if request_path != predicted_path:
logger.debug(
"%s request returned path %s instead of predicted path %s",
method,
request_path,
predicted_path,
)
subscriptions.append(self._subscribe_response(request_path, outcome))
outcome = self._await_response(method, request_path, outcome, budget)
finally:
for subscription in subscriptions:
self._conn.signal_unsubscribe(subscription)
return outcome["results"]
@staticmethod
@@ -561,13 +620,12 @@ class RemoteDesktopPortal:
self._available = True
persist = self._version >= 2
session_token = f"vocalinux_{os.getpid()}_{int(time.time())}"
session_token = _new_token()
results = self._request(
"CreateSession",
GLib.Variant(
"(a{sv})",
(self._sv_options({"session_handle_token": session_token}),),
),
"(a{sv})",
(),
{"session_handle_token": session_token},
)
session_handle = results.lookup_value(
@@ -590,11 +648,15 @@ class RemoteDesktopPortal:
try:
self._request(
"SelectDevices",
GLib.Variant("(oa{sv})", (session_path, self._sv_options(select_options))),
"(oa{sv})",
(session_path,),
select_options,
)
start_results = self._request(
"Start",
GLib.Variant("(osa{sv})", (session_path, "", {})),
"(osa{sv})",
(session_path, ""),
{},
timeout_s=_START_TIMEOUT_S,
)
except Exception:
+69 -31
View File
@@ -1174,45 +1174,70 @@ class TextInjector:
logger.warning(f"RemoteDesktop portal shortcut failed: {e}")
return False, list(steps)
def _demote_portal_backend(self) -> bool:
"""Drop the portal for this run and pick the next Wayland tool.
Mirrors the startup preference: ydotool with a ready daemon, then
ydotool alone when wtype is absent, then wtype, then the XWayland
fallback. Returns False when nothing else exists, leaving the
clipboard fallback to report the failure.
"""
def _close_portal_client(self) -> None:
"""Close the RemoteDesktop portal client and clear its reference."""
portal = getattr(self, "_portal", None)
if portal is not None:
try:
portal.close()
except Exception as e:
logger.debug(f"Could not close RemoteDesktop portal client: {e}")
self._portal = None
with self._state_lock:
self.wayland_tool = None
self._portal = None
def _demote_portal_backend(self) -> bool:
"""Drop the portal for this run and pick the next Wayland tool.
Mirrors the startup preference: ydotool with a ready daemon, then
ydotool alone when wtype is absent, then wtype, then the XWayland
fallback. Returns False when nothing else exists, leaving the
clipboard fallback to report the failure. In a Flatpak, keep the
portal and reset its session when ydotoold is unavailable so the next
attempt can create a fresh session.
"""
flatpak = bool(os.environ.get("FLATPAK_ID"))
ydotool_available = shutil.which("ydotool") is not None
wtype_available = shutil.which("wtype") is not None
xdotool_available = shutil.which("xdotool") is not None
tool = None
if ydotool_available and self._ensure_ydotoold():
tool = "ydotool"
elif ydotool_available and not wtype_available:
tool = "ydotool"
elif wtype_available:
tool = "wtype"
elif xdotool_available:
with self._state_lock:
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
logger.warning("RemoteDesktop portal failed; using xdotool/XWayland fallback")
return True
else:
if flatpak and (not ydotool_available or not self._ensure_ydotoold()):
portal = getattr(self, "_portal", None)
if portal is not None:
try:
portal.reset_session()
except Exception as e:
logger.debug(f"Could not reset RemoteDesktop session for retry: {e}")
logger.warning(
"RemoteDesktop portal failed; keeping it for the next attempt "
"(the session will be recreated on the next attempt; no working "
"ydotool daemon is available in the Flatpak)"
)
return False
self._close_portal_client()
if not flatpak:
with self._state_lock:
self.wayland_tool = None
if flatpak:
tool = "ydotool"
else:
wtype_available = shutil.which("wtype") is not None
xdotool_available = shutil.which("xdotool") is not None
tool = None
if ydotool_available and self._ensure_ydotoold():
tool = "ydotool"
elif ydotool_available and not wtype_available:
tool = "ydotool"
elif wtype_available:
tool = "wtype"
elif xdotool_available:
with self._state_lock:
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
logger.warning("RemoteDesktop portal failed; using xdotool/XWayland fallback")
return True
else:
return False
with self._state_lock:
self.wayland_tool = tool
logger.warning(f"RemoteDesktop portal failed; falling back to {tool}")
return True
@@ -2736,10 +2761,7 @@ class TextInjector:
portal_ok, remaining_text = self._try_inject_with_portal(text)
if portal_ok:
return
if not self._demote_portal_backend():
raise RuntimeError(
"RemoteDesktop portal injection failed and no Wayland fallback is available"
)
demoted = self._demote_portal_backend()
if remaining_text is None:
# The timed-out job never reported its delivered count, so
# retyping any part could duplicate what already arrived.
@@ -2749,6 +2771,22 @@ class TextInjector:
if not remaining_text:
return
typed_prefix = len(text) - len(remaining_text)
if not demoted:
if typed_prefix:
raise _PartiallyTyped(
typed_prefix,
subprocess.CalledProcessError(
1,
"portal",
stderr=(
"RemoteDesktop portal injection failed and no Wayland fallback "
"is available"
),
),
)
raise RuntimeError(
"RemoteDesktop portal injection failed and no Wayland fallback is available"
)
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
try:
self._inject_with_xdotool(remaining_text)