mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-09 05:51:00 +03:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user