From 4d018dd035f0f9b38ce861f3626600f8cff77268 Mon Sep 17 00:00:00 2001 From: Davi Ficanha Henrique Date: Sat, 17 Oct 2026 00:05:10 -0300 Subject: [PATCH] daemon: Allow clients to cancel requests xdg-desktop-portal calls org.freedesktop.impl.portal.Request.Close() on the handler when a client closes its request or disconnects, but the daemon never exported such an object, so requests could not be cancelled. Accept an optional `request_handle` object path in the CreateCredential and GetCredential options. For the duration of the ceremony, export an org.freedesktop.impl.portal.Request object at that path. Close() is only accepted from the connection that started the ceremony; it cancels a token that is passed down to the flow controller, which cancels the request and dismisses the UI. The cancellation does not need the gateway service lock, which is held for the whole ceremony. The method call then returns response code 1 (cancelled). Fixes #90 --- ARCHITECTURE.md | 6 +- CHANGELOG.md | 1 + credentialsd/src/dbus/flow_control.rs | 128 +++++++- credentialsd/src/gateway/dbus.rs | 422 ++++++++++++++++++++++++-- credentialsd/src/gateway/mod.rs | 5 + doc/api.md | 44 +++ 6 files changed, 576 insertions(+), 30 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9551e7e..6694e321 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,8 +102,10 @@ method to start the process that hosts the UI Control API. Credential requests on the Gateway are handled one at a time; if a new request comes in, it is immediately rejected, and the client is expected to retry if -necessary. Currently, clients cannot cancel their own requests; the user has to -do that via the UI. +necessary. Clients can cancel their own requests by passing a `request_handle` +option: the Gateway exports an `org.freedesktop.impl.portal.Request` object at +that path for the duration of the request, and calling `Close()` on it cancels +the request and dismisses the UI. Otherwise, the user has to cancel via the UI. ### `credentialsd/src/credential_service/` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a06a327..5caa1dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Improvements - daemon: Cancel ceremony if UI disappears (Thank you, @michaelbeaumont!) +- daemon: Allow clients to cancel requests through the `request_handle` option. - daemon: Fix UI service failing to launch when started manually instead of via D-Bus activation. - daemon: Allow Firefox to contact daemon on more distros with different app IDs (Thank you, @michaelbeaumont!) - daemon: Fix all subsequent requests being rejected after the UI failed to launch. diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index cbdc9c5e..51b5b9d2 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -17,6 +17,7 @@ use tokio::sync::mpsc::Receiver; use tokio::sync::oneshot; use tokio::sync::{Mutex as AsyncMutex, mpsc::Sender}; use tokio::task::AbortHandle; +use tokio_util::sync::CancellationToken; use zbus::connection::Connection; use zbus::zvariant::OwnedObjectPath; @@ -27,7 +28,9 @@ use crate::{ model::{CredentialRequest, CredentialResponse}, }; use crate::{ - credential_service::{CredentialServiceError, DeviceStateUpdate, ManageDevice, nfc::NfcState}, + credential_service::{ + CredentialServiceError, DeviceStateUpdate, ManageDevice, RequestId, nfc::NfcState, + }, model::ClientDetails, }; use crate::{dbus::ui_control::Ceremony, gateway::WebAuthnError}; @@ -38,6 +41,8 @@ pub struct UiRequestContext { /// Client window handle window_handle: Option, activation_token: Option, + /// Cancelled when the client closes the request before it completes. + client_cancellation: CancellationToken, response_channel: oneshot::Sender>, } @@ -60,6 +65,7 @@ pub async fn start_flow_control_service, activation_token: Option, + client_cancellation: CancellationToken, ) -> Result { + if client_cancellation.is_cancelled() { + return Err(CredentialServiceError::Internal( + "Request cancelled by client before it started.".to_string(), + )); + } let (request_tx, request_rx) = oneshot::channel(); let (request_id, cancellation_token) = svc.lock().await.init_request(&msg, request_tx).await?; let operation = msg.operation(); @@ -144,6 +157,26 @@ async fn handle>>> = Arc::new(Mutex::new(None)); let set_pin_tx: Arc>>> = Arc::new(Mutex::new(None)); @@ -297,6 +330,25 @@ async fn handle( + svc: &AsyncMutex, + request_id: RequestId, + request_cancellation: &CancellationToken, + client_cancellation: &CancellationToken, +) -> bool { + tokio::select! { + biased; + _ = request_cancellation.cancelled() => false, + _ = client_cancellation.cancelled() => { + tracing::debug!(%request_id, "Client closed the request, cancelling"); + svc.lock().await.cancel_request(request_id).await; + true + } + } +} + fn forward_background_event_stream( flow: Ceremony, mut stream: impl Stream + Send + Unpin + 'static, @@ -323,6 +375,7 @@ pub trait CredentialRequestController { request: CredentialRequest, window_handle: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result; } @@ -338,6 +391,7 @@ impl CredentialRequestController for CredentialRequestControllerClient { request: CredentialRequest, window_handle: Option, activation_token: Option, + client_cancellation: CancellationToken, ) -> Result { let (tx, rx) = oneshot::channel(); self.initiator @@ -346,6 +400,7 @@ impl CredentialRequestController for CredentialRequestControllerClient { app, window_handle, activation_token, + client_cancellation, response_channel: tx, }) .await @@ -367,10 +422,8 @@ mod tests { use std::pin::Pin; use credentialsd_common::model::{Device, Operation}; - use tokio_util::sync::CancellationToken; use super::*; - use crate::credential_service::RequestId; const TEST_REQUEST_ID: RequestId = 42; @@ -466,6 +519,7 @@ mod tests { app, None, None, + CancellationToken::new(), ) .await; @@ -475,4 +529,72 @@ mod tests { vec![TEST_REQUEST_ID] ); } + + #[tokio::test] + async fn test_client_cancellation_cancels_request() { + let svc = AsyncMutex::new(MockDeviceManager::default()); + let request_cancellation = CancellationToken::new(); + let client_cancellation = CancellationToken::new(); + client_cancellation.cancel(); + + let cancelled = cancel_on_client_request( + &svc, + TEST_REQUEST_ID, + &request_cancellation, + &client_cancellation, + ) + .await; + + assert!(cancelled); + assert_eq!( + *svc.lock().await.cancelled.lock().unwrap(), + vec![TEST_REQUEST_ID] + ); + } + + #[tokio::test] + async fn test_finished_request_is_not_cancelled_by_client() { + let svc = AsyncMutex::new(MockDeviceManager::default()); + let request_cancellation = CancellationToken::new(); + let client_cancellation = CancellationToken::new(); + request_cancellation.cancel(); + client_cancellation.cancel(); + + let cancelled = cancel_on_client_request( + &svc, + TEST_REQUEST_ID, + &request_cancellation, + &client_cancellation, + ) + .await; + + assert!(!cancelled); + assert!(svc.lock().await.cancelled.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn test_cancelled_client_request_does_not_start() { + let svc = Arc::new(AsyncMutex::new(MockDeviceManager::default())); + let app = ClientDetails { + app_id: "org.example.App".to_string(), + pid: 1, + }; + let client_cancellation = CancellationToken::new(); + client_cancellation.cancel(); + + let result = handle( + svc.clone(), + FailingUiController, + create_test_request().await, + app, + None, + None, + client_cancellation, + ) + .await; + + assert!(result.is_err()); + // The request was never initialized, so there is nothing to cancel. + assert!(svc.lock().await.cancelled.lock().unwrap().is_empty()); + } } diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index a39f63d5..e087cb0d 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -7,11 +7,12 @@ use std::{ use serde::{Deserialize, Serialize, ser::SerializeTuple}; use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; use zbus::{ - Connection, DBusError, interface, + Connection, DBusError, fdo, interface, message::Header, - names::{BusName, UniqueName}, - zvariant::{DeserializeDict, Optional, Type, Value}, + names::{BusName, OwnedUniqueName, UniqueName}, + zvariant::{DeserializeDict, Optional, OwnedObjectPath, Type, Value}, }; use credentialsd_common::model::WindowHandle; @@ -29,6 +30,9 @@ use super::{GatewayService, RequestContext, check_origin_from_app}; pub const PORTAL_SERVICE_PATH: &str = "/org/freedesktop/portal/desktop"; +/// Prefix of the request object paths created by xdg-desktop-portal. +const PORTAL_REQUEST_PATH_PREFIX: &str = "/org/freedesktop/portal/desktop/request/"; + pub(super) async fn start_dbus_gateway( svc: Arc>, ) -> Result { @@ -78,6 +82,7 @@ impl CredentialPortalGateway { activation_token, top_origin, public_key, + request_handle, } = options; let request_json = match (&cred_type, public_key) { @@ -113,15 +118,29 @@ impl CredentialPortalGateway { public_key: Some(CreatePublicKeyCredentialRequest { request_json }), }; - let response = self - .gateway_service - .lock() - .await - .handle_create_credential(request, context, parent_window.into(), activation_token) - .await - .map_err(Error::from); - - response.into() + run_ceremony( + connection, + header.sender(), + request_handle, + |cancellation| async move { + let Some(svc) = cancellation + .run_until_cancelled(self.gateway_service.lock()) + .await + else { + return Err(Error::AbortError); + }; + svc.handle_create_credential( + request, + context, + parent_window.into(), + activation_token, + cancellation, + ) + .await + .map_err(Error::from) + }, + ) + .await } #[zbus(out_args("response", "results"))] @@ -138,6 +157,7 @@ impl CredentialPortalGateway { activation_token, top_origin, public_key, + request_handle, } = options; let app_validation_result = validate_app_details( connection, @@ -169,14 +189,116 @@ impl CredentialPortalGateway { public_key: Some(GetPublicKeyCredentialRequest { request_json }), }; - let response = self - .gateway_service - .lock() - .await - .handle_get_credential(request, context, parent_window.into(), activation_token) - .await - .map_err(Error::from); - response.into() + run_ceremony( + connection, + header.sender(), + request_handle, + |cancellation| async move { + let Some(svc) = cancellation + .run_until_cancelled(self.gateway_service.lock()) + .await + else { + return Err(Error::AbortError); + }; + svc.handle_get_credential( + request, + context, + parent_window.into(), + activation_token, + cancellation, + ) + .await + .map_err(Error::from) + }, + ) + .await + } +} + +/// Runs a ceremony that the client can cancel. +/// +/// If `request_handle` is set, an `org.freedesktop.impl.portal.Request` object +/// is exported at that path for the duration of the ceremony. Calling `Close()` +/// on it cancels the token passed to `ceremony`, and the ceremony is reported +/// as cancelled. +async fn run_ceremony( + connection: &Connection, + sender: Option<&UniqueName<'_>>, + request_handle: Option, + ceremony: F, +) -> PortalResult +where + F: FnOnce(CancellationToken) -> Fut, + Fut: Future>, +{ + let cancellation = CancellationToken::new(); + let Some(request_handle) = request_handle else { + return ceremony(cancellation).await.into(); + }; + + let Some(sender) = sender else { + return Err(Error::SecurityError).into(); + }; + if !request_handle + .as_str() + .starts_with(PORTAL_REQUEST_PATH_PREFIX) + { + tracing::warn!(%request_handle, "Invalid request handle passed. Rejecting request."); + return Err(Error::TypeError).into(); + } + let request = PortalRequest { + caller: sender.to_owned().into(), + cancellation: cancellation.clone(), + }; + match connection + .object_server() + .at(request_handle.as_ref(), request) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::warn!(%request_handle, "Request handle already in use. Rejecting request."); + return Err(Error::TypeError).into(); + } + Err(err) => return Err(Error::ZBus(err)).into(), + } + + let result = ceremony(cancellation.clone()).await; + + if let Err(err) = connection + .object_server() + .remove::(request_handle.as_ref()) + .await + { + tracing::warn!(%request_handle, %err, "Failed to remove request handle"); + } + + if result.is_err() && cancellation.is_cancelled() { + tracing::debug!(%request_handle, "Request cancelled by client"); + PortalResult::cancelled() + } else { + result.into() + } +} + +/// Request object that lets the caller cancel an in-flight ceremony. +struct PortalRequest { + /// Unique bus name of the caller that started the ceremony. + caller: OwnedUniqueName, + cancellation: CancellationToken, +} + +#[interface(name = "org.freedesktop.impl.portal.Request")] +impl PortalRequest { + async fn close(&self, #[zbus(header)] header: Header<'_>) -> fdo::Result<()> { + if header.sender() != Some(&self.caller) { + tracing::warn!(sender = ?header.sender(), "Rejecting request to close request from untrusted caller"); + return Err(fdo::Error::AccessDenied( + "Only the caller that started the request may close it".to_string(), + )); + } + self.cancellation.cancel(); + Ok(()) } } @@ -209,6 +331,10 @@ struct CreateCredentialPortalOptions { /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. public_key: Option, + + /// Path at which to export an `org.freedesktop.impl.portal.Request` + /// object that the caller can use to cancel the request. + request_handle: Option, } #[derive(Debug, DeserializeDict, Type)] @@ -225,6 +351,10 @@ struct GetCredentialPortalOptions { /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. public_key: Option, + + /// Path at which to export an `org.freedesktop.impl.portal.Request` + /// object that the caller can use to cancel the request. + request_handle: Option, } #[allow(clippy::enum_variant_names)] @@ -288,14 +418,21 @@ impl From for Error { #[derive(Serialize)] enum PortalResponse { Success = 0, - // Cancelled = 1, + Cancelled = 1, Other = 2, } #[derive(Type)] #[zvariant(signature = "ua{sv}")] struct PortalResult { - inner: Result, + /// `None` if the request was cancelled. + inner: Option>, +} + +impl PortalResult { + fn cancelled() -> Self { + PortalResult { inner: None } + } } impl Serialize for PortalResult @@ -309,14 +446,18 @@ where { let mut map = serializer.serialize_tuple(2)?; match &self.inner { - Err(err) => { + None => { + map.serialize_element(&(PortalResponse::Cancelled as u32))?; + map.serialize_element(&HashMap::<&str, Value<'_>>::new())?; + } + Some(Err(err)) => { map.serialize_element(&(PortalResponse::Other as u32))?; map.serialize_element(&HashMap::<&str, Value<'_>>::from([( "error", Value::Str(err.to_string().into()), )]))?; } - Ok(response) => { + Some(Ok(response)) => { map.serialize_element(&(PortalResponse::Success as u32))?; map.serialize_element(&response)?; } @@ -327,7 +468,7 @@ where impl From> for PortalResult { fn from(value: Result) -> Self { - PortalResult { inner: value } + PortalResult { inner: Some(value) } } } @@ -429,3 +570,234 @@ async fn query_peer_pidfd( ) .ok() } + +#[cfg(test)] +mod test { + use std::{collections::HashMap, rc::Rc}; + + use gio::{TestDBus, TestDBusFlags}; + use tokio::sync::oneshot; + use zbus::zvariant::{LE, ObjectPath, OwnedValue, Value, serialized::Context, to_bytes}; + + use super::*; + + const REQUEST_HANDLE: &str = "/org/freedesktop/portal/desktop/request/1_42/t"; + + type TestResponse = HashMap<&'static str, Value<'static>>; + + fn encode( + value: &T, + ) -> zbus::zvariant::serialized::Data<'static, 'static> { + to_bytes(Context::new_dbus(LE, 0), value).unwrap() + } + + fn options(request_handle: Option<&str>) -> HashMap<&'static str, Value<'static>> { + let mut options = HashMap::from([ + ("top_origin", Value::from("https://example.com")), + ("public_key", Value::from("{}")), + ]); + if let Some(handle) = request_handle { + let path = ObjectPath::try_from(handle.to_string()).unwrap(); + options.insert("request_handle", Value::from(path)); + } + options + } + + fn decode_portal_result( + result: PortalResult>, Error>, + ) -> (u32, HashMap) { + encode(&result).deserialize().unwrap().0 + } + + #[test] + fn test_create_options_without_request_handle() { + let (options, _): (CreateCredentialPortalOptions, _) = + encode(&options(None)).deserialize().unwrap(); + assert_eq!(options.public_key.as_deref(), Some("{}")); + assert!(options.request_handle.is_none()); + } + + #[test] + fn test_create_options_with_request_handle() { + let (options, _): (CreateCredentialPortalOptions, _) = + encode(&options(Some(REQUEST_HANDLE))) + .deserialize() + .unwrap(); + assert_eq!(options.request_handle.unwrap().as_str(), REQUEST_HANDLE); + } + + #[test] + fn test_get_options_without_request_handle() { + let (options, _): (GetCredentialPortalOptions, _) = + encode(&options(None)).deserialize().unwrap(); + assert_eq!(options.top_origin.as_deref(), Some("https://example.com")); + assert!(options.request_handle.is_none()); + } + + #[test] + fn test_get_options_with_request_handle() { + let (options, _): (GetCredentialPortalOptions, _) = encode(&options(Some(REQUEST_HANDLE))) + .deserialize() + .unwrap(); + assert_eq!(options.request_handle.unwrap().as_str(), REQUEST_HANDLE); + } + + #[test] + fn test_portal_result_response_codes() { + let (code, results) = decode_portal_result(Ok(HashMap::new()).into()); + assert_eq!(code, PortalResponse::Success as u32); + assert!(results.is_empty()); + + let (code, results) = decode_portal_result(PortalResult::cancelled()); + assert_eq!(code, PortalResponse::Cancelled as u32); + assert!(results.is_empty()); + + let (code, results) = decode_portal_result(Err(Error::NotAllowedError).into()); + assert_eq!(code, PortalResponse::Other as u32); + assert!(results.contains_key("error")); + } + + #[tokio::test] + async fn test_ceremony_without_request_handle_is_not_cancellable() { + let result = run_ceremony( + &connect_test_bus(None).await.0, + None, + None, + |cancellation| async move { + assert!(!cancellation.is_cancelled()); + Ok(TestResponse::new()) + }, + ) + .await; + assert_eq!( + decode_portal_result(result).0, + PortalResponse::Success as u32 + ); + } + + #[tokio::test] + async fn test_invalid_request_handle_is_rejected() { + let (connection, _bus) = connect_test_bus(None).await; + let handle = OwnedObjectPath::try_from(PORTAL_SERVICE_PATH).unwrap(); + let result = run_ceremony( + &connection, + connection.unique_name().map(|n| n.inner()), + Some(handle), + |_| async { Ok(TestResponse::new()) }, + ) + .await; + assert!(matches!(result.inner, Some(Err(Error::TypeError)))); + } + + #[tokio::test] + async fn test_close_cancels_ceremony() { + let (gateway, bus) = connect_test_bus(None).await; + let (caller, _) = connect_test_bus(Some(&bus)).await; + let (other, _) = connect_test_bus(Some(&bus)).await; + let handle = OwnedObjectPath::try_from(REQUEST_HANDLE).unwrap(); + let (started_tx, started_rx) = oneshot::channel(); + + let ceremony = run_ceremony( + &gateway, + caller.unique_name().map(|n| n.inner()), + Some(handle.clone()), + |cancellation| async move { + started_tx.send(()).unwrap(); + // Like the credential service, fail once the request is cancelled. + cancellation.cancelled().await; + Err::(Error::NotAllowedError) + }, + ); + let client = async { + started_rx.await.unwrap(); + let err = close_request(&other, &gateway, &handle).await.unwrap_err(); + assert!(matches!(err, fdo::Error::AccessDenied(_)), "{err:?}"); + close_request(&caller, &gateway, &handle).await.unwrap(); + }; + let (result, ()) = tokio::join!(ceremony, client); + + let (code, _) = decode_portal_result(result); + assert_eq!(code, PortalResponse::Cancelled as u32); + // The request object is removed when the ceremony ends. + assert!(close_request(&caller, &gateway, &handle).await.is_err()); + } + + #[tokio::test] + async fn test_request_handle_removed_after_success() { + let (gateway, bus) = connect_test_bus(None).await; + let (caller, _) = connect_test_bus(Some(&bus)).await; + let handle = OwnedObjectPath::try_from(REQUEST_HANDLE).unwrap(); + + let result = run_ceremony( + &gateway, + caller.unique_name().map(|n| n.inner()), + Some(handle.clone()), + |_| async { Ok(TestResponse::new()) }, + ) + .await; + + assert_eq!( + decode_portal_result(result).0, + PortalResponse::Success as u32 + ); + assert!(close_request(&caller, &gateway, &handle).await.is_err()); + } + + /// Connects to a private test bus, starting one if `bus` is `None`. + async fn connect_test_bus(bus: Option<&TestBus>) -> (Connection, TestBus) { + let bus = match bus { + Some(bus) => bus.clone(), + None => TestBus::new(), + }; + let connection = zbus::connection::Builder::address(bus.address.as_str()) + .unwrap() + .build() + .await + .unwrap(); + (connection, bus) + } + + async fn close_request( + caller: &Connection, + gateway: &Connection, + handle: &OwnedObjectPath, + ) -> fdo::Result<()> { + caller + .call_method( + gateway.unique_name(), + handle, + Some("org.freedesktop.impl.portal.Request"), + "Close", + &(), + ) + .await + .map(|_| ()) + .map_err(fdo::Error::from) + } + + #[derive(Clone)] + struct TestBus { + address: String, + _bus: Rc, + } + + struct TestBusGuard(TestDBus); + + impl Drop for TestBusGuard { + fn drop(&mut self) { + self.0.down(); + } + } + + impl TestBus { + fn new() -> Self { + let bus = TestDBus::new(TestDBusFlags::NONE); + bus.up(); + let address = bus.bus_address().expect("test bus to be up").to_string(); + Self { + address, + _bus: Rc::new(TestBusGuard(bus)), + } + } + } +} diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 644591ec..e1a52cfb 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -14,6 +14,7 @@ use std::{ use credentialsd_common::model::WindowHandle; use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; use zbus::{ Connection, zvariant::{DeserializeDict, NoneValue, OwnedValue, SerializeDict, Type}, @@ -83,6 +84,7 @@ impl GatewayService { context: RequestContext, parent_window: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result { let request_environment = validate_request(&context)?; @@ -115,6 +117,7 @@ impl GatewayService { cred_request, parent_window, activation_token, + cancellation, ) .await?; @@ -148,6 +151,7 @@ impl GatewayService { context: RequestContext, parent_window: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result { let request_environment = validate_request(&context)?; @@ -177,6 +181,7 @@ impl GatewayService { cred_request, parent_window, activation_token, + cancellation, ) .await?; diff --git a/doc/api.md b/doc/api.md index d445b615..b385dbbe 100644 --- a/doc/api.md +++ b/doc/api.md @@ -58,6 +58,7 @@ sequenceDiagram ### Improvements - Document errors returned to gateway requests +- (Gateway): Add optional `request_handle` option to allow clients to cancel requests ## [0.1.0] - 2025-08-14 @@ -204,6 +205,7 @@ CreateCredentialRequest( IN options a{sv} { activation_token: s top_origin: s + request_handle: o }, IN app_id s, @@ -212,6 +214,8 @@ CreateCredentialRequest( For information on `parent_window`, see [Window Identifiers](#window-identifiers). +For information on `request_handle`, see [Cancelling Requests](#cancelling-requests). + > TODO: We should make this a tagged enum ``` @@ -322,6 +326,7 @@ GetCredentialRequest ( IN options a{sv} { activation_token: s top_origin: s + request_handle: o public_key: s }, @@ -331,6 +336,8 @@ GetCredentialRequest ( For information on `parent_window`, see [Window Identifiers](#window-identifiers). +For information on `request_handle`, see [Cancelling Requests](#cancelling-requests). + Note that while only one credential type can be specified in `CreateCredential()`, credential types in this `GetCredential()` are not mutually exclusive: as new credential types are added to the specification, a client may @@ -402,6 +409,43 @@ denoted by `type: "publicKey"`: - `TypeError`: An invalid request is made. - `NotAllowedError`: catch-all error. +## Cancelling Requests + +`CreateCredential()` and `GetCredential()` return a response code and results, +following the [XDG Desktop Portal `Response` conventions][xdg-request]: + +- `0`: Success. The results contain the credential. +- `1`: The request was cancelled by the client. The results are empty. +- `2`: The request failed. The results contain an `error` string. + +A caller that wants to be able to cancel an in-flight request passes the +optional `request_handle` option, an object path below +`/org/freedesktop/portal/desktop/request/`. For the duration of the request, the +gateway exports an object implementing `org.freedesktop.impl.portal.Request` at +that path: + +``` +org.freedesktop.impl.portal.Request { + Close() +} +``` + +Calling `Close()` cancels the request and dismisses the UI, and the original +method call returns response code `1`. `Close()` is only accepted from the same +D-Bus connection that made the `CreateCredential()` or `GetCredential()` call; +other callers receive `org.freedesktop.DBus.Error.AccessDenied`. The object is +removed when the request ends, whether it succeeded, failed or was cancelled. + +If the path is already in use or is outside of the prefix above, the request is +rejected with `TypeError`. If `request_handle` is omitted, the request cannot be +cancelled. + +xdg-desktop-portal passes the path of the `Request` object it created for the +client, so that closing that request, or the client disconnecting, cancels the +ceremony. + +[xdg-request]: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + # Flow Control API The Flow Control API is used by the UI to pass user interactions through the