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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user