Skip to main content

frust_devtools/
backend.rs

1//! [`DevtoolsBackend`] — the seam between the wire service and whatever holds
2//! the live app state (a shell, in production; a fake, in tests).
3//!
4//! The trait is **plain synchronous Rust**: no `async fn`, no `Future`, no
5//! tokio type in any signature. A shell implements it the way it would
6//! implement any other trait, and the service does the async work behind it
7//! (`crate::hop`). See the crate doc's *Threading & blocking model* for what
8//! that costs and guarantees.
9
10use frust_devtools_protocol::{
11    Capability, HandshakeInfo, InputScrollParams, InputTapParams, MetricsSnapshot,
12    PROTOCOL_VERSION, RpcError, ScreenshotResult, WidgetProps, WidgetTreeDump,
13};
14
15/// The app identity a service announces at handshake, supplied by whoever
16/// starts the service (the shell knows its own app name and framework
17/// version; the backend does not have to).
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AppInfo {
20    pub app_name: String,
21    pub frust_version: String,
22}
23
24impl AppInfo {
25    pub fn new(app_name: impl Into<String>, frust_version: impl Into<String>) -> Self {
26        Self {
27            app_name: app_name.into(),
28            frust_version: frust_version.into(),
29        }
30    }
31}
32
33/// Why a backend call could not be satisfied. Each variant maps to exactly one
34/// JSON-RPC error code (see [`BackendError::to_rpc_error`]), so a backend
35/// picks the variant and never has to know the code.
36///
37/// Hand-rolled `Display`/`Error` rather than `thiserror`-derived (the usual
38/// convention in `docs/CODE_STANDARDS.md`'s Error Handling) because this crate
39/// keeps a deliberately minimal dependency set — the same trade its sibling
40/// `frust-devtools-protocol` makes for `DecodeError`.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum BackendError {
43    /// The backend does not implement this capability at all (the default
44    /// `screenshot` answer). Distinct from a transient failure: a client that
45    /// reads the handshake capability set should never have asked.
46    NotSupported(String),
47    /// The capability exists but nothing can serve it right now (no attached
48    /// window, no live widget tree yet, app backgrounded).
49    Unavailable(String),
50    /// The request itself was wrong (an id that names no widget, an
51    /// out-of-range coordinate).
52    InvalidRequest(String),
53    /// Anything else the backend failed at.
54    Internal(String),
55}
56
57impl BackendError {
58    pub fn not_supported(what: impl Into<String>) -> Self {
59        BackendError::NotSupported(what.into())
60    }
61
62    pub fn unavailable(what: impl Into<String>) -> Self {
63        BackendError::Unavailable(what.into())
64    }
65
66    pub fn invalid_request(what: impl Into<String>) -> Self {
67        BackendError::InvalidRequest(what.into())
68    }
69
70    pub fn internal(what: impl Into<String>) -> Self {
71        BackendError::Internal(what.into())
72    }
73
74    /// The wire error this failure becomes.
75    pub fn to_rpc_error(&self) -> RpcError {
76        match self {
77            BackendError::NotSupported(m) => RpcError::new(RpcError::NOT_SUPPORTED, m.clone()),
78            BackendError::Unavailable(m) => RpcError::new(RpcError::INTERNAL_ERROR, m.clone()),
79            BackendError::InvalidRequest(m) => RpcError::new(RpcError::INVALID_PARAMS, m.clone()),
80            BackendError::Internal(m) => RpcError::new(RpcError::INTERNAL_ERROR, m.clone()),
81        }
82    }
83}
84
85impl std::fmt::Display for BackendError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            BackendError::NotSupported(m) => write!(f, "not supported: {m}"),
89            BackendError::Unavailable(m) => write!(f, "unavailable: {m}"),
90            BackendError::InvalidRequest(m) => write!(f, "invalid request: {m}"),
91            BackendError::Internal(m) => write!(f, "internal backend error: {m}"),
92        }
93    }
94}
95
96impl std::error::Error for BackendError {}
97
98/// What the devtools service asks the app for.
99///
100/// # Threading
101///
102/// Every method here is called from the service's own **backend thread**, one
103/// call at a time, never from the frame/UI thread and never concurrently. An
104/// implementation that needs UI-thread state does the hop itself (post to the
105/// UI thread and block on the answer) — that is the one thing this trait
106/// cannot do for it, because only the shell knows what its UI thread is. The
107/// service caps every call with a timeout, so a hop that never comes back
108/// costs the caller an error response, never a hung client
109/// (`crate::hop::BackendClient`).
110///
111/// `handshake_info` is the exception: it is called **once**, on the thread that
112/// calls [`crate::Service::start`], and the answer is cached for the process
113/// lifetime — which is what lets `handshake` be answered statelessly at any
114/// time, including while the UI thread is wedged.
115pub trait DevtoolsBackend: Send + 'static {
116    /// Server identity + the capability set clients gate on. Called once at
117    /// startup, on the starting thread (see the trait doc).
118    ///
119    /// The default answers with everything except
120    /// [`Capability::Screenshot`], matching the default [`Self::screenshot`]
121    /// below — override this together with `screenshot`, never one alone.
122    fn handshake_info(&self, app: &AppInfo) -> HandshakeInfo {
123        HandshakeInfo {
124            app_name: app.app_name.clone(),
125            frust_version: app.frust_version.clone(),
126            protocol_version: PROTOCOL_VERSION,
127            capabilities: vec![
128                Capability::WidgetTree,
129                Capability::FrameStats,
130                Capability::Input,
131                Capability::Metrics,
132            ],
133        }
134    }
135
136    /// A snapshot of the retained widget tree.
137    ///
138    /// Infallible on purpose: "nothing built yet" is an empty
139    /// [`WidgetTreeDump::roots`], not an error — a client polling a
140    /// just-launched app should see an empty tree, not a failure it has to
141    /// distinguish from a broken backend.
142    fn widget_tree(&self) -> WidgetTreeDump;
143
144    /// One widget's inspectable properties, or `None` when `id` names no live
145    /// widget (the service turns that into `INVALID_PARAMS` — a stale id from
146    /// a tree the app has since rebuilt is an ordinary, expected case, not a
147    /// backend failure).
148    fn widget_props(&self, id: u64) -> Option<WidgetProps>;
149
150    /// Process-level metrics (uptime, best-effort RSS). Infallible for
151    /// [`Self::widget_tree`]'s reason — an unavailable RSS is
152    /// [`MetricsSnapshot::rss_bytes`] `None`, not a failed call.
153    fn metrics_snapshot(&self) -> MetricsSnapshot;
154
155    /// Synthesize a tap at the given logical-px point.
156    fn inject_tap(&self, params: InputTapParams) -> Result<(), BackendError>;
157
158    /// Synthesize a scroll at the given logical-px point.
159    fn inject_scroll(&self, params: InputScrollParams) -> Result<(), BackendError>;
160
161    /// Synthesize text input against the focused widget.
162    fn inject_text(&self, text: &str) -> Result<(), BackendError>;
163
164    /// Capture the current frame as a PNG.
165    ///
166    /// Defaults to [`BackendError::NotSupported`]: a screenshot needs a
167    /// readback path the backend may not have, so it is capability-gated
168    /// rather than mandatory (`RpcError::NOT_SUPPORTED` on the wire). A
169    /// backend that overrides this must also declare
170    /// [`Capability::Screenshot`] in [`Self::handshake_info`].
171    fn screenshot(&self) -> Result<ScreenshotResult, BackendError> {
172        Err(BackendError::not_supported(
173            "screenshot capture is not implemented by this backend",
174        ))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn each_backend_error_maps_to_its_own_rpc_code() {
184        assert_eq!(
185            BackendError::not_supported("x").to_rpc_error().code,
186            RpcError::NOT_SUPPORTED
187        );
188        assert_eq!(
189            BackendError::invalid_request("x").to_rpc_error().code,
190            RpcError::INVALID_PARAMS
191        );
192        assert_eq!(
193            BackendError::unavailable("x").to_rpc_error().code,
194            RpcError::INTERNAL_ERROR
195        );
196        assert_eq!(
197            BackendError::internal("x").to_rpc_error().code,
198            RpcError::INTERNAL_ERROR
199        );
200    }
201
202    #[test]
203    fn rpc_error_message_carries_the_backend_message() {
204        let err = BackendError::unavailable("no window attached").to_rpc_error();
205        assert_eq!(err.message, "no window attached");
206    }
207
208    #[test]
209    fn backend_error_is_a_std_error() {
210        let err = BackendError::internal("boom");
211        let _: &dyn std::error::Error = &err;
212        assert_eq!(err.to_string(), "internal backend error: boom");
213    }
214}