Skip to main content

lamco_portal/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! XDG Desktop Portal integration for Wayland screen capture and input control
4//!
5//! This library provides a high-level Rust interface to the XDG Desktop Portal,
6//! specifically the ScreenCast, RemoteDesktop, and Clipboard interfaces. It enables
7//! applications to capture screen content via PipeWire and inject input events
8//! on Wayland compositors.
9//!
10//! # Features
11//!
12//! - **Screen capture**: Capture monitor or window content through PipeWire streams
13//! - **Input injection**: Send keyboard and mouse events to the desktop
14//! - **Clipboard integration**: Portal-based clipboard for remote desktop scenarios
15//! - **Multi-monitor support**: Handle multiple displays simultaneously
16//! - **Flexible configuration**: Builder pattern and struct literals for Portal options
17//! - **Typed errors**: Handle different failure modes appropriately
18//!
19//! # Requirements
20//!
21//! This library requires:
22//! - A Wayland compositor (e.g., GNOME, KDE Plasma, Sway)
23//! - `xdg-desktop-portal` installed and running
24//! - A portal backend for your compositor (e.g., `xdg-desktop-portal-gnome`)
25//! - PipeWire for video streaming
26//!
27//! # Quick Start
28//!
29//! ```no_run
30//! use lamco_portal::{PortalManager, PortalConfig};
31//!
32//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
33//! // Create portal manager with default config
34//! let manager = PortalManager::with_default().await?;
35//!
36//! // Create a session (triggers permission dialog)
37//! let (session, restore_token) = manager.create_session("my-session".to_string(), None).await?;
38//!
39//! // Access PipeWire file descriptor for video capture
40//! let fd = session.pipewire_fd();
41//! let streams = session.streams();
42//!
43//! println!("Capturing {} streams on PipeWire FD {}", streams.len(), fd);
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! # Configuration
49//!
50//! Customize Portal behavior using [`PortalConfig`]:
51//!
52//! ```no_run
53//! use lamco_portal::{PortalManager, PortalConfig};
54//! use ashpd::desktop::screencast::CursorMode;
55//! use ashpd::desktop::PersistMode;
56//!
57//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
58//! let config = PortalConfig::builder()
59//!     .cursor_mode(CursorMode::Embedded)  // Embed cursor in video
60//!     .persist_mode(PersistMode::Application)  // Remember permission
61//!     .build();
62//!
63//! let manager = PortalManager::new(config).await?;
64//! # Ok(())
65//! # }
66//! ```
67//!
68//! # Input Injection
69//!
70//! Send keyboard and mouse events through the RemoteDesktop portal:
71//!
72//! ```no_run
73//! # use lamco_portal::{PortalManager, PortalConfig};
74//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
75//! # let manager = PortalManager::with_default().await?;
76//! # let (session, _token) = manager.create_session("my-session".to_string(), None).await?;
77//! // Move mouse to absolute position
78//! manager.remote_desktop()
79//!     .notify_pointer_motion_absolute(
80//!         session.ashpd_session(),
81//!         0,      // stream index
82//!         100.0,  // x position
83//!         200.0,  // y position
84//!     )
85//!     .await?;
86//!
87//! // Click mouse button
88//! manager.remote_desktop()
89//!     .notify_pointer_button(
90//!         session.ashpd_session(),
91//!         1,      // button 1 (left)
92//!         true,   // pressed
93//!     )
94//!     .await?;
95//! # Ok(())
96//! # }
97//! ```
98//!
99//! # Error Handling
100//!
101//! The library uses typed errors via [`PortalError`]:
102//!
103//! ```no_run
104//! # use lamco_portal::{PortalManager, PortalConfig, PortalError};
105//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
106//! # let manager = PortalManager::with_default().await?;
107//! match manager.create_session("my-session".to_string(), None).await {
108//!     Ok((session, _token)) => {
109//!         println!("Session created successfully");
110//!     }
111//!     Err(PortalError::PermissionDenied) => {
112//!         eprintln!("User denied permission in dialog");
113//!     }
114//!     Err(PortalError::PortalNotAvailable) => {
115//!         eprintln!("Portal not installed - install xdg-desktop-portal");
116//!     }
117//!     Err(e) => {
118//!         eprintln!("Other error: {}", e);
119//!     }
120//! }
121//! # Ok(())
122//! # }
123//! ```
124//!
125//! # Platform Notes
126//!
127//! - **GNOME**: Works out of the box with `xdg-desktop-portal-gnome`
128//! - **KDE Plasma**: Use `xdg-desktop-portal-kde`
129//! - **wlroots** (Sway, etc.): Use `xdg-desktop-portal-wlr`
130//! - **X11**: Not supported - Wayland only
131//!
132//! # Security
133//!
134//! This library triggers system permission dialogs. Users must explicitly grant:
135//! - Screen capture access (which monitors/windows to share)
136//! - Input injection access (keyboard/mouse control)
137//! - Clipboard access (if using clipboard features)
138//!
139//! Permissions can be remembered per-application using [`ashpd::desktop::PersistMode::Application`].
140
141use std::sync::Arc;
142use tracing::{debug, info, trace, warn};
143
144pub mod clipboard;
145pub mod config;
146pub mod error;
147pub mod remote_desktop;
148pub mod screencast;
149pub mod session;
150
151// Optional ClipboardSink implementation (requires lamco-clipboard-core)
152#[cfg(feature = "clipboard-sink")]
153pub mod clipboard_sink;
154
155// Optional D-Bus clipboard bridge for GNOME fallback
156#[cfg(feature = "dbus-clipboard")]
157pub mod dbus_clipboard;
158
159pub use clipboard::ClipboardManager;
160pub use config::{PortalConfig, PortalConfigBuilder};
161pub use error::{PortalError, Result};
162pub use remote_desktop::RemoteDesktopManager;
163pub use screencast::ScreenCastManager;
164
165// Re-export ClipboardSink implementation when feature is enabled
166#[cfg(feature = "clipboard-sink")]
167pub use clipboard_sink::PortalClipboardSink;
168
169// Re-export D-Bus clipboard bridge types when feature is enabled
170#[cfg(feature = "dbus-clipboard")]
171pub use dbus_clipboard::{DbusClipboardBridge, DbusClipboardEvent};
172
173pub use session::{PortalSessionHandle, SourceType, StreamInfo};
174
175/// Portal manager coordinates all portal interactions
176///
177/// This is the main entry point for interacting with XDG Desktop Portals.
178/// It manages the lifecycle of Portal sessions and provides access to
179/// specialized managers for screen capture, input injection, and clipboard.
180///
181/// # Lifecycle
182///
183/// 1. Create a `PortalManager` with [`PortalManager::new`] or [`PortalManager::with_default`]
184/// 2. Create a session with [`PortalManager::create_session`] (triggers permission dialog)
185/// 3. Use the session for screen capture via PipeWire and input injection
186/// 4. Clean up with [`PortalManager::cleanup`] when done
187///
188/// # Examples
189///
190/// ```no_run
191/// use lamco_portal::{PortalManager, PortalConfig};
192///
193/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
194/// // Simple usage with defaults
195/// let manager = PortalManager::with_default().await?;
196/// let session = manager.create_session("session-1".to_string(), None).await?;
197///
198/// // Access specialized managers
199/// let screencast = manager.screencast();
200/// let remote_desktop = manager.remote_desktop();
201/// # Ok(())
202/// # }
203/// ```
204pub struct PortalManager {
205    config: PortalConfig,
206    #[allow(dead_code)]
207    connection: zbus::Connection,
208    screencast: Arc<ScreenCastManager>,
209    remote_desktop: Arc<RemoteDesktopManager>,
210    clipboard: Option<Arc<ClipboardManager>>,
211}
212
213impl PortalManager {
214    /// Create new portal manager with specified configuration
215    ///
216    /// # Examples
217    ///
218    /// With defaults:
219    /// ```no_run
220    /// # use lamco_portal::{PortalManager, PortalConfig};
221    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
222    /// let manager = PortalManager::new(PortalConfig::default()).await?;
223    /// # Ok(())
224    /// # }
225    /// ```
226    ///
227    /// With custom config:
228    /// ```no_run
229    /// # use lamco_portal::{PortalManager, PortalConfig};
230    /// # use ashpd::desktop::screencast::CursorMode;
231    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
232    /// let config = PortalConfig {
233    ///     cursor_mode: CursorMode::Embedded,
234    ///     ..Default::default()
235    /// };
236    /// let manager = PortalManager::new(config).await?;
237    /// # Ok(())
238    /// # }
239    /// ```
240    pub async fn new(config: PortalConfig) -> Result<Self> {
241        info!("Initializing Portal Manager");
242
243        // Connect to session D-Bus
244        let connection = zbus::Connection::session().await?;
245
246        // Log connection details for debugging session issues
247        if let Some(unique_name) = connection.unique_name() {
248            debug!(
249                dbus_unique_name = %unique_name,
250                "Connected to D-Bus session bus"
251            );
252        } else {
253            debug!("Connected to D-Bus session bus (no unique name yet)");
254        }
255
256        let screencast = Arc::new(ScreenCastManager::new(connection.clone(), &config).await?);
257
258        let remote_desktop =
259            Arc::new(RemoteDesktopManager::new(connection.clone(), &config).await?);
260
261        // Clipboard manager requires a RemoteDesktop session
262        // It will be created after session is established in create_session_with_clipboard()
263
264        info!("Portal Manager initialized successfully");
265
266        Ok(Self {
267            config,
268            connection,
269            screencast,
270            remote_desktop,
271            clipboard: None, // Created later with session
272        })
273    }
274
275    /// Create new portal manager with default configuration
276    ///
277    /// # Examples
278    ///
279    /// ```no_run
280    /// # use lamco_portal::PortalManager;
281    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
282    /// let manager = PortalManager::with_default().await?;
283    /// # Ok(())
284    /// # }
285    /// ```
286    pub async fn with_default() -> Result<Self> {
287        Self::new(PortalConfig::default()).await
288    }
289
290    /// Create a complete portal session (ScreenCast for video, RemoteDesktop for input, optionally Clipboard)
291    ///
292    /// This triggers the user permission dialog and returns a session handle
293    /// with PipeWire access for video and input injection capabilities.
294    ///
295    /// # Arguments
296    ///
297    /// * `session_id` - Unique identifier for this session (user-provided)
298    /// * `clipboard` - Optional Clipboard manager to enable for this session
299    ///
300    /// # Flow
301    ///
302    /// 1. Create combined RemoteDesktop session (includes ScreenCast capability)
303    /// 2. Select devices (keyboard + pointer for input injection)
304    /// 3. Select sources (monitors to capture for screen sharing)
305    /// 4. Request clipboard access (if clipboard provided) ← BEFORE START
306    /// 5. Start session (triggers permission dialog, unless restore token valid)
307    /// 6. Get PipeWire FD, stream information, and restore token
308    ///
309    /// # Returns
310    ///
311    /// Tuple of (PortalSessionHandle, Optional restore token)
312    ///
313    /// The restore token should be stored securely and passed in the next
314    /// session's PortalConfig to avoid permission dialogs.
315    ///
316    /// # Examples
317    ///
318    /// ```no_run
319    /// # use lamco_portal::{PortalManager, PortalConfig};
320    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
321    /// let manager = PortalManager::new(PortalConfig::default()).await?;
322    /// let session = manager.create_session("my-session-1".to_string(), None).await?;
323    /// # Ok(())
324    /// # }
325    /// ```
326    pub async fn create_session(
327        &self,
328        session_id: String,
329        clipboard: Option<&crate::clipboard::ClipboardManager>,
330    ) -> Result<(PortalSessionHandle, Option<String>)> {
331        info!("Creating combined portal session (ScreenCast + RemoteDesktop)");
332
333        // RemoteDesktop session type supports both input injection and screen sharing
334        let remote_desktop_session =
335            self.remote_desktop.create_session().await.map_err(|e| {
336                PortalError::session_creation(format!("RemoteDesktop session: {}", e))
337            })?;
338
339        // Log session creation for clipboard debugging
340        // Note: ashpd Session.path() is private, so we generate our own tracking ID
341        let session_tracking_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
342        info!(
343            session_id = %session_tracking_id,
344            "RemoteDesktop session created"
345        );
346        trace!(
347            session_id = %session_tracking_id,
348            "Session state is INIT (required for clipboard.request)"
349        );
350
351        // Portal spec requires clipboard.request() when session state is INIT,
352        // which means we must call it before SelectDevices or SelectSources.
353        if let Some(clipboard_mgr) = clipboard {
354            debug!(session_id = %session_tracking_id, "Requesting clipboard access");
355
356            match clipboard_mgr
357                .portal_clipboard()
358                .request(&remote_desktop_session)
359                .await
360            {
361                Ok(()) => {
362                    info!(session_id = %session_tracking_id, "Clipboard enabled for session");
363                }
364                Err(e) => {
365                    warn!(
366                        session_id = %session_tracking_id,
367                        error = %e,
368                        "Clipboard request failed"
369                    );
370                    if format!("{}", e).contains("Invalid state") {
371                        warn!("Portal daemon may have stale session state - clipboard unavailable");
372                    }
373                }
374            }
375        }
376
377        // Select devices for input injection (from config)
378        // Must close session on any error to prevent orphaned D-Bus state.
379        // ashpd's Session does NOT implement Drop with Close() - we must do it explicitly.
380        if let Err(e) = self
381            .remote_desktop
382            .select_devices(&remote_desktop_session, self.config.devices)
383            .await
384        {
385            warn!("Device selection failed, closing session: {}", e);
386            let _ = remote_desktop_session.close().await;
387            return Err(PortalError::session_creation(format!(
388                "Device selection: {}",
389                e
390            )));
391        }
392
393        info!("Input devices selected from config");
394
395        // ScreenCast is required to make screen sources available for sharing
396        let screencast_proxy = ashpd::desktop::screencast::Screencast::new().await?;
397
398        if let Err(e) = screencast_proxy
399            .select_sources(
400                &remote_desktop_session,
401                self.config.cursor_mode,
402                self.config.source_type,
403                self.config.allow_multiple,
404                self.config.restore_token.as_deref(),
405                self.config.persist_mode,
406            )
407            .await
408        {
409            // Close session before returning to prevent GNOME Shell from tracking stale state.
410            // Without cleanup, retry attempts fail with "Invalid state" from portal daemon.
411            warn!("Source selection failed, closing session: {}", e);
412            let _ = remote_desktop_session.close().await;
413            return Err(PortalError::session_creation(format!(
414                "Source selection: {}",
415                e
416            )));
417        }
418
419        info!("Screen sources selected - permission dialog will appear");
420
421        // Start the combined session (triggers permission dialog, unless restore token valid)
422        // Note: clipboard.request() was already called earlier, immediately after CreateSession
423        let (pipewire_fd, streams, restore_token) = match self
424            .remote_desktop
425            .start_session(&remote_desktop_session)
426            .await
427        {
428            Ok(result) => result,
429            Err(e) => {
430                warn!("Session start failed, closing session: {}", e);
431                let _ = remote_desktop_session.close().await;
432                return Err(PortalError::session_creation(format!(
433                    "Session start: {}",
434                    e
435                )));
436            }
437        };
438
439        info!("Portal session started successfully");
440        info!("  PipeWire FD: {:?}", pipewire_fd);
441        info!("  Streams: {}", streams.len());
442
443        if let Some(ref token) = restore_token {
444            info!("  Restore Token: Received ({} chars)", token.len());
445        } else {
446            debug!("  Restore Token: None (portal may not support persistence)");
447        }
448
449        if streams.is_empty() {
450            warn!("No streams available, closing session");
451            let _ = remote_desktop_session.close().await;
452            return Err(PortalError::NoStreamsAvailable);
453        }
454
455        // Keep session alive for input injection to remain functional
456        let stream_count = streams.len();
457        let handle = PortalSessionHandle::new(
458            session_id.clone(),
459            pipewire_fd,
460            streams,
461            Some(session_id.clone()), // Store session ID for input operations
462            remote_desktop_session,   // Pass the actual ashpd session for input injection
463        );
464
465        info!(
466            "Portal session handle created with {} streams",
467            stream_count
468        );
469
470        Ok((handle, restore_token))
471    }
472
473    /// Access the ScreenCast manager
474    ///
475    /// Use this to access ScreenCast-specific functionality if needed.
476    /// Most users will use [`PortalManager::create_session`] instead.
477    pub fn screencast(&self) -> &Arc<ScreenCastManager> {
478        &self.screencast
479    }
480
481    /// Access the RemoteDesktop manager
482    ///
483    /// Use this to inject input events (keyboard, mouse, scroll) into
484    /// the desktop session. Requires an active session from
485    /// [`PortalManager::create_session`].
486    ///
487    /// # Examples
488    ///
489    /// ```no_run
490    /// # use lamco_portal::PortalManager;
491    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
492    /// # let manager = PortalManager::with_default().await?;
493    /// # let (session, _token) = manager.create_session("s1".to_string(), None).await?;
494    /// // Inject mouse movement
495    /// manager.remote_desktop()
496    ///     .notify_pointer_motion_absolute(
497    ///         session.ashpd_session(),
498    ///         0, 100.0, 200.0
499    ///     )
500    ///     .await?;
501    /// # Ok(())
502    /// # }
503    /// ```
504    pub fn remote_desktop(&self) -> &Arc<RemoteDesktopManager> {
505        &self.remote_desktop
506    }
507
508    /// Access the Clipboard manager if available
509    ///
510    /// Returns `None` if no clipboard manager has been set.
511    /// Clipboard integration is optional and must be explicitly enabled.
512    pub fn clipboard(&self) -> Option<&Arc<ClipboardManager>> {
513        self.clipboard.as_ref()
514    }
515
516    /// Set clipboard manager (called after session creation)
517    ///
518    /// This is typically used internally during session setup.
519    /// Most users should not need to call this directly.
520    pub fn set_clipboard(&mut self, clipboard: Arc<ClipboardManager>) {
521        self.clipboard = Some(clipboard);
522    }
523
524    /// Cleanup all portal resources
525    ///
526    /// Portal sessions are automatically cleaned up when dropped,
527    /// so calling this explicitly is optional. It can be useful for
528    /// logging cleanup or performing graceful shutdown.
529    pub async fn cleanup(&self) -> Result<()> {
530        info!("Cleaning up portal resources");
531        // Portal sessions are automatically cleaned up when dropped
532        Ok(())
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[tokio::test]
541    #[ignore] // Requires Wayland session
542    async fn test_portal_manager_creation() {
543        let config = PortalConfig::default();
544        let manager = PortalManager::new(config).await;
545
546        // May fail if not in Wayland session or portal not available
547        if manager.is_err() {
548            eprintln!("Portal manager creation failed (expected if not in Wayland session)");
549        }
550    }
551
552    #[tokio::test]
553    #[ignore] // Requires Wayland session
554    async fn test_portal_manager_with_default() {
555        let manager = PortalManager::with_default().await;
556
557        // May fail if not in Wayland session or portal not available
558        if manager.is_err() {
559            eprintln!("Portal manager creation failed (expected if not in Wayland session)");
560        }
561    }
562}