wayland: say which step of the portal handshake failed

The XDG portal handshake is four sequential requests, and every way it can end
badly -- the user declining, nobody answering, the portal being absent or
ending it or dying mid-handshake, the stream list coming back empty -- left
`request_remote_desktop` through one `bail!` carrying one string.
`map_err_scrap` then guessed a cause by looking for "dbus" or "pipewire" in
that string. Since that string always mentions "PipeWire library", a decline
and a three-minute timeout both came out as "Wayland requires higher version of
linux distro. Please try X11 desktop or change your OS." On Ubuntu 21+, where
the mapping passes the text through untouched, they came out as raw English
pointing at an unrelated GitHub issue.

The response code and the D-Bus error were in hand at the moment of failure and
were being dropped: `handle_response` collapsed all of it into one
`AtomicBool`. Record it instead, tagged with the stage that produced it, and
let the app side look the tag up. `map_err_scrap` gains one leading branch;
anything untagged -- which is everything the capture loop reports -- takes the
existing path unchanged.

The tag decides between five new keys and the existing `xdp-portal-unavailable`:

- A decline, nobody answering, the request being dismissed, an empty stream
  list, and a missing GStreamer element each get their own message. They say
  what happened in words a person can act on; the response code and the D-Bus
  error stay in the log, where they are worth something. Sentence-case English,
  so a peer that has never heard of them falls back to the key and still reads
  as a sentence rather than showing a slug like `x11 expected`.
- Everything else is the portal failing to deliver, which is what
  `xdp-portal-unavailable` already says -- it is already translated everywhere
  and carries the one remedy a user can act on, `systemctl --user restart
  xdg-desktop-portal`. The D-Bus error name and message go to the log.
- The Ubuntu-before-21 branch keeps every outcome that says something about the
  machine and yields the two that say what a person did.

`fill_displays` needs the tag resolved at its own call site: it opens a second
portal session for cursor-based display disambiguation, and its error returns
straight up `check_init` without passing through `map_err_scrap`, so a tag
would otherwise reach the peer verbatim.

Two existing paths change, both necessarily:

- `check_init` no longer wraps `Capturer::new` in `with_context`. The peer is
  shown `format!("{}", err)` (connection.rs), which renders only the outermost
  layer, so that context was replacing the mapped code with "Failed to create
  capturer for display 0".
- The `std::process::exit(-1)` on libdbus' no-reply text is now reached only by
  the capture loop, which is what that self-heal was written for. Every D-Bus
  call in the handshake -- opening the session bus, `get_request_path`, the
  `add_match` inside `handle_response`, `create_session`, and `conn.process` in
  the wait loop -- carries a tag, so a no-reply there is reported rather than
  fatal. It is worth saying plainly what that branch did before: the portal
  proxy has a one-second timeout, so a portal slow to activate could take the
  whole service down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
This commit is contained in:
rustdesk
2026-09-09 07:36:07 +08:00
parent e5d473407e
commit 3652240786
2 changed files with 366 additions and 35 deletions

View File

@@ -21,6 +21,7 @@ lazy_static::lazy_static! {
static ref CAP_DISPLAY_INFO: RwLock<HashMap<usize, u64>> = RwLock::new(HashMap::new());
static ref PIPEWIRE_INITIALIZED: RwLock<bool> = RwLock::new(false);
static ref LOG_SCRAP_COUNT: Mutex<u32> = Mutex::new(0);
static ref LAST_STAGE_ERR: Mutex<Option<(String, std::time::Instant)>> = Mutex::new(None);
static ref ACTIVE_DISPLAY_COUNT: RwLock<usize> = RwLock::new(0);
}
@@ -44,11 +45,23 @@ pub(super) fn decrement_active_display_count() -> usize {
fn map_err_scrap(err: String) -> io::Error {
// to-do: Handle error better, do not restart server
// Reached by the capture loop, which is what this crude self-heal was for. A no-reply
// during the portal handshake is tagged below and is reported instead of exiting: at
// login there is someone waiting to be told, and a portal that is slow to activate is
// not a reason to take the service down.
if err.starts_with("Did not receive a reply") {
log::error!("Fatal pipewire error, {}", &err);
std::process::exit(-1);
}
if let Some(tag) = err.strip_prefix(WAYLAND_STAGE_TAG) {
log_staged_once(&err);
return io::Error::new(
io::ErrorKind::Other,
staged_message(tag, is_ubuntu_before_21()),
);
}
if DISTRO.name.to_uppercase() == "Ubuntu".to_uppercase() {
if DISTRO.version_id < "21".to_owned() {
io::Error::new(io::ErrorKind::Other, SCRAP_UBUNTU_HIGHER_REQUIRED)
@@ -74,6 +87,38 @@ fn map_err_scrap(err: String) -> io::Error {
}
}
/// `Display::all` and `Capturer::new` reach the peer through `map_err_scrap`, but
/// `fill_displays` opens a portal session of its own and returns its error straight up, so a
/// tag has to be resolved here or it lands in the login dialog verbatim.
fn map_staged_err(err: anyhow::Error) -> anyhow::Error {
let text = err.to_string();
match text.strip_prefix(WAYLAND_STAGE_TAG) {
Some(tag) => {
log_staged_once(&text);
anyhow::anyhow!(staged_message(tag, is_ubuntu_before_21()))
}
None => err,
}
}
// The video service retries about once a second, so a wedged portal would otherwise write a
// line a second forever. Repeat the message only when the cause changes, or after long
// enough that a reader would want to see the fault is still there.
const STAGE_ERR_REPEAT: std::time::Duration = std::time::Duration::from_secs(600);
fn log_staged_once(err: &str) {
let now = std::time::Instant::now();
let mut last = LAST_STAGE_ERR.lock().unwrap();
let repeat = match last.as_ref() {
Some((seen, at)) => seen != err || now.duration_since(*at) >= STAGE_ERR_REPEAT,
None => true,
};
if repeat {
log::error!("Wayland portal handshake failed: {}", err);
*last = Some((err.to_owned(), now));
}
}
fn try_log(err: &String) {
let mut lock_count = LOG_SCRAP_COUNT.lock().unwrap();
if *lock_count >= 1000000 {
@@ -85,6 +130,133 @@ fn try_log(err: &String) {
*lock_count += 1;
}
// Translation keys, so the key itself is the English text: an older peer that has never heard
// of them falls back to displaying the key and still reads as a sentence.
const WAYLAND_DECLINED: &str = "The screen sharing request was declined on the remote device";
const WAYLAND_NO_ANSWER: &str =
"No one responded to the screen sharing request on the remote device";
const WAYLAND_PORTAL_DISMISSED: &str =
"The screen sharing request was dismissed on the remote device";
// The remedy the message it replaces used to carry, minus the link: this is the outcome
// rustdesk/rustdesk#8600 is about.
const WAYLAND_NO_SCREEN: &str =
"The XDG Desktop Portal returned no screen to capture, the PipeWire library may be too old";
const WAYLAND_GST_MISSING: &str = "A GStreamer plugin needed for screen capture is missing ({})";
const WAYLAND_STAGE_TAG: &str = "wl-stage:";
// `translate()` on the peer strips the braces itself, so what goes on the wire is the key
// with the detail still *inside* the placeholder.
fn with_detail(key: &str, detail: &str) -> String {
key.replace("{}", &format!("{{{}}}", detail))
}
fn is_ubuntu_before_21() -> bool {
DISTRO.name.to_uppercase() == "Ubuntu".to_uppercase() && DISTRO.version_id < "21".to_owned()
}
/// Maps a `<stage>:<kind>:<detail>` tag from the portal handshake, see
/// `scrap::wayland::pipewire`, onto what to tell the peer. Everything the capture loop reports
/// carries no tag and keeps the legacy substring heuristics above.
fn staged_message(tag: &str, ubuntu_before_21: bool) -> String {
let mut parts = tag.splitn(3, ':');
let stage = parts.next().unwrap_or_default();
let kind = parts.next().unwrap_or_default();
let detail = parts.next().unwrap_or_default().trim();
// An outcome that says something about the machine is what the Ubuntu branch was written
// for, so that branch keeps it. An outcome that says what a person did is a fact no distro
// check can improve on.
let of_the_machine = |msg: &str| {
if ubuntu_before_21 {
SCRAP_UBUNTU_HIGHER_REQUIRED.to_owned()
} else {
msg.to_owned()
}
};
match (stage, kind) {
(_, "declined") => WAYLAND_DECLINED.to_owned(),
(_, "portal-error") => WAYLAND_PORTAL_DISMISSED.to_owned(),
("start", "no-response") => WAYLAND_NO_ANSWER.to_owned(),
("streams", _) => of_the_machine(WAYLAND_NO_SCREEN),
("gst-plugin", _) => of_the_machine(&with_detail(WAYLAND_GST_MISSING, detail)),
// Everything else is the portal not delivering, which is what this key already says --
// and unlike a message of our own it carries the `systemctl --user restart` remedy.
_ => of_the_machine(SCRAP_XDP_PORTAL_UNAVAILABLE),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_message_names_the_stage() {
let m = |tag| staged_message(tag, false);
assert_eq!(m("start:declined:"), WAYLAND_DECLINED);
assert_eq!(m("start:no-response:"), WAYLAND_NO_ANSWER);
assert_eq!(m("start:portal-error:2"), WAYLAND_PORTAL_DISMISSED);
assert_eq!(m("streams:empty:"), WAYLAND_NO_SCREEN);
}
#[test]
fn a_portal_that_did_not_deliver_keeps_the_message_that_says_how_to_restart_it() {
let m = |tag| staged_message(tag, false);
assert_eq!(
m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
assert_eq!(
m("create-session:no-response:"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
assert_eq!(
m("select-sources:internal:no session_handle"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
// A tag this build does not know must never fall back to a guess.
assert_eq!(
m("some-new-stage:some-new-kind:x"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
assert_eq!(m(""), SCRAP_XDP_PORTAL_UNAVAILABLE);
}
// The peer resolves a message by replacing its first `{...}` with `{}` and looking that
// up, so every detail-carrying message has to reduce back to its key exactly.
#[test]
fn a_detail_carrying_message_reduces_back_to_its_key() {
let reduce = |s: &str| {
let open = s.find('{').expect("no placeholder");
let close = s[open..].find('}').expect("unclosed placeholder") + open;
format!("{}{{}}{}", &s[..open], &s[close + 1..])
};
let gst = staged_message("gst-plugin:missing:pipewiresrc", false);
assert_eq!(
gst,
"A GStreamer plugin needed for screen capture is missing ({pipewiresrc})"
);
assert_eq!(reduce(&gst), WAYLAND_GST_MISSING);
}
#[test]
fn legacy_ubuntu_keeps_its_message_for_machine_faults_only() {
let m = |tag| staged_message(tag, true);
assert_eq!(
m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"),
SCRAP_UBUNTU_HIGHER_REQUIRED
);
assert_eq!(
m("gst-plugin:missing:pipewiresrc"),
SCRAP_UBUNTU_HIGHER_REQUIRED
);
assert_eq!(m("streams:empty:"), SCRAP_UBUNTU_HIGHER_REQUIRED);
assert_eq!(m("start:declined:"), WAYLAND_DECLINED);
assert_eq!(m("start:no-response:"), WAYLAND_NO_ANSWER);
}
}
struct CapturerPtr(*mut Capturer);
impl Clone for CapturerPtr {
@@ -312,7 +484,8 @@ pub(super) async fn check_init() -> ResultType<()> {
{
let temp_mouse_move_handle = input_service::TemporaryMouseMoveHandle::new();
let move_mouse_to = |x, y| temp_mouse_move_handle.move_mouse_to(x, y);
fill_displays(move_mouse_to, crate::get_cursor_pos, &mut all)?;
fill_displays(move_mouse_to, crate::get_cursor_pos, &mut all)
.map_err(map_staged_err)?;
}
log::debug!("Attempting to fix logical size with try_fix_logical_size()");
try_fix_logical_size(&mut all);
@@ -340,10 +513,9 @@ pub(super) async fn check_init() -> ResultType<()> {
// Create individual CapDisplayInfo for each display with its own capturer
for (idx, display) in all.into_iter().enumerate() {
let capturer =
Box::into_raw(Box::new(Capturer::new(display).with_context(|| {
format!("Failed to create capturer for display {}", idx)
})?));
// No `with_context` here: the peer is shown `format!("{}", err)`, which
// renders only the outermost layer, and the mapped reason is the inner one.
let capturer = Box::into_raw(Box::new(Capturer::new(display)?));
let capturer = CapturerPtr(capturer);
let cap_display_info = Box::into_raw(Box::new(CapDisplayInfo {