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}