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}