Skip to main content

wlr_capture/
error.rs

1//! The capture engine's typed error surface.
2//!
3//! The crate depends on `thiserror` only — no `anyhow` in its public API. Conditions a
4//! caller may want to react to (present a message, pick a fallback, translate) get their
5//! own variant; every lower-level failure (EGL, FFmpeg, PipeWire, Wayland, GL readback, IO)
6//! flows through [`CaptureError::Backend`], which keeps a human context **and** the
7//! underlying cause via `#[source]`, so the error chain is preserved. Variant text is plain
8//! technical English, deliberately **not** localised — this is a library, so callers
9//! translate by matching the variant.
10
11use std::error::Error as StdError;
12use thiserror::Error;
13
14/// An error from the capture engine.
15#[derive(Debug, Error)]
16pub enum CaptureError {
17    /// The compositor can't capture individual windows: no foreign-toplevel image-capture
18    /// source (wlroots < 0.20 / Sway < 1.12). Screen capture may still work.
19    #[error(
20        "this compositor cannot capture individual windows (needs wlroots >= 0.20 / Sway >= 1.12)"
21    )]
22    WindowsUnsupported,
23
24    /// No outputs are available to capture.
25    #[error("no outputs available")]
26    NoOutputs,
27
28    /// The requested region has zero area.
29    #[error("empty region")]
30    EmptyRegion,
31
32    /// The requested region lies outside every output.
33    #[error("region covers no output")]
34    RegionOffscreen,
35
36    /// No output matches the requested name.
37    #[error("output '{0}' not found")]
38    OutputNotFound(String),
39
40    /// No window matches the requested id.
41    #[error("window '{0}' not found")]
42    WindowNotFound(String),
43
44    /// No window matches the requested `app_id` / title filter.
45    #[error("no window matches {0}")]
46    NoWindowMatches(String),
47
48    /// Several windows match the requested `app_id` / title filter, so the caller has to
49    /// narrow it down. `candidates` describes the matches well enough to pick one.
50    #[error("{selector} matches {} windows:\n{}", .candidates.len(), .candidates.join("\n"))]
51    AmbiguousWindow {
52        /// A human description of the filter that was too loose, e.g. `app-id 'firefox'`.
53        selector: String,
54        /// One pre-formatted line per matching window.
55        candidates: Vec<String>,
56    },
57
58    /// A geometry string was not in `X,Y WxH` form.
59    #[error("invalid geometry '{0}' (expected 'X,Y WxH')")]
60    InvalidGeometry(String),
61
62    /// The capture produced no frame before the deadline.
63    #[error("capture timed out")]
64    CaptureTimeout,
65
66    /// No usable H.264 encoder is available (NVENC, VAAPI or libx264).
67    #[cfg(feature = "video")]
68    #[error("no H.264 encoder available (need NVENC, VAAPI or libx264)")]
69    NoVideoEncoder,
70
71    /// The source is too small to encode.
72    #[cfg(feature = "video")]
73    #[error("source too small to encode ({w}x{h})")]
74    SourceTooSmall {
75        /// Source width in pixels.
76        w: u32,
77        /// Source height in pixels.
78        h: u32,
79    },
80
81    /// No audio capture backend is available.
82    #[cfg(feature = "audio")]
83    #[error("no audio backend available")]
84    NoAudioBackend,
85
86    /// A lower-level failure, with a human context and (when there is one) the underlying
87    /// cause preserved so the error chain survives.
88    #[error("{context}")]
89    Backend {
90        /// What was being attempted.
91        context: String,
92        /// The underlying cause, if any.
93        #[source]
94        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
95    },
96}
97
98/// The crate's result type: `Result<T, CaptureError>`. Mirrors `anyhow::Result` so a call
99/// site keeps its `Result<T>` signatures and only swaps the import.
100pub type Result<T, E = CaptureError> = std::result::Result<T, E>;
101
102impl CaptureError {
103    /// A message-only backend error (no underlying cause) — the typed replacement for a
104    /// bare `anyhow!("…")` / `bail!("…")`.
105    pub fn msg(context: impl Into<String>) -> Self {
106        CaptureError::Backend {
107            context: context.into(),
108            source: None,
109        }
110    }
111}
112
113/// Attach context to a fallible value, mapping its error into [`CaptureError::Backend`]
114/// while preserving the original cause. Mirrors `anyhow::Context`, so a call site only has
115/// to import `crate::error::Context` instead of `anyhow::Context`.
116pub trait Context<T> {
117    /// Wrap the error with a fixed context message.
118    fn context(self, context: impl Into<String>) -> Result<T, CaptureError>;
119    /// Wrap the error with a lazily-built context message (only computed on error).
120    fn with_context<S: Into<String>>(self, f: impl FnOnce() -> S) -> Result<T, CaptureError>;
121}
122
123impl<T, E: StdError + Send + Sync + 'static> Context<T> for Result<T, E> {
124    fn context(self, context: impl Into<String>) -> Result<T, CaptureError> {
125        self.map_err(|e| CaptureError::Backend {
126            context: context.into(),
127            source: Some(Box::new(e)),
128        })
129    }
130    fn with_context<S: Into<String>>(self, f: impl FnOnce() -> S) -> Result<T, CaptureError> {
131        self.map_err(|e| CaptureError::Backend {
132            context: f().into(),
133            source: Some(Box::new(e)),
134        })
135    }
136}
137
138impl<T> Context<T> for Option<T> {
139    fn context(self, context: impl Into<String>) -> Result<T, CaptureError> {
140        self.ok_or_else(|| CaptureError::msg(context))
141    }
142    fn with_context<S: Into<String>>(self, f: impl FnOnce() -> S) -> Result<T, CaptureError> {
143        self.ok_or_else(|| CaptureError::msg(f()))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn source(e: &CaptureError) -> Option<&(dyn StdError + 'static)> {
152        StdError::source(e)
153    }
154
155    #[test]
156    fn variant_display_text_is_stable() {
157        assert_eq!(
158            CaptureError::OutputNotFound("HDMI-1".into()).to_string(),
159            "output 'HDMI-1' not found"
160        );
161        assert_eq!(
162            CaptureError::WindowNotFound("42".into()).to_string(),
163            "window '42' not found"
164        );
165        assert_eq!(
166            CaptureError::InvalidGeometry("bad".into()).to_string(),
167            "invalid geometry 'bad' (expected 'X,Y WxH')"
168        );
169        assert!(
170            CaptureError::WindowsUnsupported
171                .to_string()
172                .contains("wlroots >= 0.20")
173        );
174    }
175
176    #[test]
177    fn ambiguous_window_lists_every_candidate() {
178        let e = CaptureError::AmbiguousWindow {
179            selector: "app-id 'firefox'".into(),
180            candidates: vec!["  1  firefox  Inbox".into(), "  2  firefox  Docs".into()],
181        };
182        assert_eq!(
183            e.to_string(),
184            "app-id 'firefox' matches 2 windows:\n  1  firefox  Inbox\n  2  firefox  Docs"
185        );
186    }
187
188    #[cfg(feature = "video")]
189    #[test]
190    fn source_too_small_interpolates_dimensions() {
191        assert_eq!(
192            CaptureError::SourceTooSmall { w: 4, h: 2 }.to_string(),
193            "source too small to encode (4x2)"
194        );
195    }
196
197    #[test]
198    fn msg_is_a_backend_error_without_a_cause() {
199        let e = CaptureError::msg("boom");
200        assert_eq!(e.to_string(), "boom");
201        assert!(source(&e).is_none());
202    }
203
204    #[test]
205    fn context_on_result_wraps_and_preserves_the_cause() {
206        // A ParseIntError is a real StdError, so it flows into `Backend { source }`.
207        let e: CaptureError = "x".parse::<i32>().context("parsing width").unwrap_err();
208        assert_eq!(e.to_string(), "parsing width");
209        let cause = source(&e).expect("cause preserved");
210        assert!(cause.to_string().contains("invalid digit"));
211    }
212
213    #[test]
214    fn with_context_is_lazy_and_only_builds_on_error() {
215        let ok: Result<i32> = "1"
216            .parse::<i32>()
217            .with_context(|| -> String { unreachable!("not called on Ok") });
218        assert_eq!(ok.unwrap(), 1);
219
220        let e = "x"
221            .parse::<i32>()
222            .with_context(|| format!("ctx {}", 7))
223            .unwrap_err();
224        assert_eq!(e.to_string(), "ctx 7");
225    }
226
227    #[test]
228    fn context_on_option_maps_none_and_passes_some_through() {
229        let e = None::<i32>.context("missing thing").unwrap_err();
230        assert_eq!(e.to_string(), "missing thing");
231        assert!(source(&e).is_none());
232
233        assert_eq!(Some(5).context("unused").unwrap(), 5);
234    }
235}