diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 2313c8868..9e7c1ccf0 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -290,10 +290,12 @@ fn main() { // A source means the app already chose X11, and the take follows it even if a // portal has appeared since. Otherwise only a portal that is missing, or that // refuses every session (#1491), falls back: one that fails later keeps its - // error. + // error. So does a version 1 portal, which offers no cursor modes (#1537): + // direct X11 reports the cursor, that portal cannot. if x11::is_x11_session() && (request.source.is_some() || match &offered_cursor_modes { + Ok(modes) if modes.is_empty() => true, Ok(_) => pollster::block_on(portal::refuses_sessions()), Err(error) => matches!(error, portal::PortalError::Unavailable(_)), }) diff --git a/electron/native/pipewire-capture/src/portal.rs b/electron/native/pipewire-capture/src/portal.rs index 6e017e1a5..0847c850c 100644 --- a/electron/native/pipewire-capture/src/portal.rs +++ b/electron/native/pipewire-capture/src/portal.rs @@ -71,7 +71,14 @@ impl CursorMode { /// pointer stays out of the pixels and the editor draws it from the samples /// instead. Any other miss is returned unchanged, for the negotiation to /// refuse with its own error. + /// + /// A portal that offers no mode at all is a version 1 ScreenCast (#1537): it + /// predates cursor modes and draws the pointer however it does. That is not + /// METADATA, so the take records as EMBEDDED rather than failing. pub fn resolve(self, offered: &[CursorMode]) -> CursorMode { + if offered.is_empty() { + return Self::Embedded; + } if self == Self::Embedded && !offered.contains(&Self::Embedded) && offered.contains(&Self::Metadata) @@ -210,14 +217,102 @@ fn portal_unavailable(error: impl std::fmt::Display) -> PortalError { )) } +/// `AvailableCursorModes` and the `cursor_mode` option both came with version 2 +/// of the ScreenCast portal. +const CURSOR_MODES_VERSION: u32 = 2; + +/// Whether this portal has cursor modes. ashpd keeps its own copy of the +/// version private, so the property is read again (zbus serves it from cache). +async fn has_cursor_modes(proxy: &Screencast<'_>) -> Result { + let version: u32 = proxy + .get_property("version") + .await + .map_err(|error| failed("ScreenCast version", error))?; + Ok(version >= CURSOR_MODES_VERSION) +} + +/// `SelectSources` for a version 1 portal, which ashpd cannot send: it always +/// sets `cursor_mode`, and xdg-desktop-portal rejects any cursor mode its v1 +/// backend did not advertise ("Unavailable cursor mode"), which is all of them. +/// The same request, minus that option, answered on its Request object. +async fn select_sources_without_cursor_mode( + proxy: &Screencast<'_>, + session: &S, + types: BitFlags, +) -> Result<(), PortalError> +where + S: serde::Serialize + ashpd::zvariant::Type, +{ + use ashpd::zbus::export::futures_util::StreamExt; + use ashpd::zvariant::{OwnedValue, Value}; + use std::collections::HashMap; + + let token = format!("openscreen{}", std::process::id()); + let sender = proxy + .connection() + .unique_name() + .ok_or_else(|| PortalError::Failed("SelectSources: no D-Bus unique name".to_owned()))?; + let request = ashpd::zbus::Proxy::new( + proxy.connection(), + "org.freedesktop.portal.Desktop", + request_path(sender.as_str(), &token), + "org.freedesktop.portal.Request", + ) + .await + .map_err(|error| failed("SelectSources", error))?; + // Subscribed before the call, or the response can land before the match rule. + let mut responses = request + .receive_signal("Response") + .await + .map_err(|error| failed("SelectSources", error))?; + + let options: HashMap<&str, Value> = HashMap::from([ + ("handle_token", Value::from(token.as_str())), + ("types", Value::from(types.bits())), + ("multiple", Value::from(false)), + ]); + proxy + .call_method("SelectSources", &(session, options)) + .await + .map_err(|error| failed("SelectSources", error))?; + + let message = responses + .next() + .await + .ok_or_else(|| PortalError::Failed("SelectSources: no response".to_owned()))?; + let (code, _): (u32, HashMap) = message + .body() + .deserialize() + .map_err(|error| failed("SelectSources response", error))?; + match code { + 0 => Ok(()), + 1 => Err(PortalError::Cancelled), + _ => Err(PortalError::Failed( + "SelectSources response: Portal request didn't succeed: Other".to_owned(), + )), + } +} + +/// Where the portal publishes the Request for a call made with `token`. +fn request_path(unique_name: &str, token: &str) -> String { + let sender = unique_name.trim_start_matches(':').replace('.', "_"); + format!("/org/freedesktop/portal/desktop/request/{sender}/{token}") +} + /// Cheap, non-interactive probe: which cursor modes does this portal offer? /// /// Split out from [`negotiate`] so the helper can answer that question — and /// emit `ready` — before anything raises a dialog. +/// +/// A version 1 portal has no `AvailableCursorModes` (it came with version 2), so +/// it offers none: reading the property there failed every take (#1537). pub async fn offered_cursor_modes() -> Result, PortalError> { let proxy = Screencast::new() .await .map_err(|error| screencast_error("ScreenCast", error))?; + if !has_cursor_modes(&proxy).await? { + return Ok(Vec::new()); + } let cursor_modes = proxy .available_cursor_modes() .await @@ -263,15 +358,23 @@ pub async fn negotiate(cursor_mode: CursorMode) -> Result PortalError::CursorMetadataUnsupported, other => PortalError::Failed(format!( @@ -288,30 +391,34 @@ pub async fn negotiate(cursor_mode: CursorMode) -> Result = SourceType::Monitor | SourceType::Window; - proxy - .select_sources( - &session, - cursor_mode.to_portal(), - types, - false, - // NO RESTORE TOKEN, AND NOTHING TO PERSIST. This used to replay a - // token from the previous run so the picker would not reappear — - // and that is precisely how "record this window" produced a - // recording of the whole screen. A token is bound to the source it - // was minted for, so once any monitor had been approved the portal - // restored that monitor on every later run and never raised the - // picker again; the app had no way to ask for anything else, - // because `SelectSources` cannot name a source. The picker IS the - // source chooser on Wayland, so suppressing it removed the only - // control the user had. Asking every time is the cost of letting - // them choose at all. - None, - PersistMode::DoNot, - ) - .await - .map_err(|error| failed("SelectSources", error))? - .response() - .map_err(|error| failed("SelectSources response", error))?; + if !has_cursor_modes { + select_sources_without_cursor_mode(&proxy, &session, types).await?; + } else { + proxy + .select_sources( + &session, + cursor_mode.to_portal(), + types, + false, + // NO RESTORE TOKEN, AND NOTHING TO PERSIST. This used to replay a + // token from the previous run so the picker would not reappear — + // and that is precisely how "record this window" produced a + // recording of the whole screen. A token is bound to the source it + // was minted for, so once any monitor had been approved the portal + // restored that monitor on every later run and never raised the + // picker again; the app had no way to ask for anything else, + // because `SelectSources` cannot name a source. The picker IS the + // source chooser on Wayland, so suppressing it removed the only + // control the user had. Asking every time is the cost of letting + // them choose at all. + None, + PersistMode::DoNot, + ) + .await + .map_err(|error| failed("SelectSources", error))? + .response() + .map_err(|error| failed("SelectSources response", error))?; + } let streams = proxy .start(&session, &WindowIdentifier::default()) @@ -374,6 +481,20 @@ mod tests { assert_eq!(Metadata.resolve(&[Metadata]), Metadata); } + #[test] + fn a_v1_portal_offering_no_mode_records_embedded() { + assert_eq!(Metadata.resolve(&[]), Embedded); + assert_eq!(Embedded.resolve(&[]), Embedded); + } + + #[test] + fn the_request_path_follows_the_sender_name() { + assert_eq!( + super::request_path(":1.42", "openscreen7"), + "/org/freedesktop/portal/desktop/request/1_42/openscreen7" + ); + } + #[test] fn a_miss_without_a_fallback_is_left_for_negotiation_to_refuse() { assert_eq!(Embedded.resolve(&[Hidden]), Embedded);