Skip to main content

frust_devtools_protocol/
messages.rs

1//! Typed request-param / result / notification-payload structs for each v1
2//! [`crate::Method`]. A [`Request::params`](crate::Request)/
3//! [`Response`](crate::Response) `result` field is transport-generic
4//! (`serde_json::Value`); a caller matches on [`crate::Method`] and
5//! `serde_json::from_value`/`to_value`s the type below that pairs with it —
6//! that pairing is a documented convention here, not something the type
7//! system enforces, since the envelope has to stay method-agnostic.
8
9use serde::{Deserialize, Serialize};
10
11/// `handshake` request params — the per-process auth token the server printed
12/// on its discovery line (`crate::format_discovery_line`).
13///
14/// **Inbound only.** The token travels client→server on this one method and
15/// appears in no result, notification, or error payload the server ever
16/// writes; a client that learned it from a log line must not echo it back
17/// anywhere else.
18///
19/// `token` is optional so a client can still handshake against a server
20/// running with auth switched off (and so a server can answer such a client's
21/// `null` params rather than failing to decode them) — a server that requires
22/// a token answers an absent one with [`crate::RpcError::UNAUTHORIZED`].
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct HandshakeParams {
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub token: Option<String>,
27}
28
29/// `handshake` result — server identity plus the declared capability set a
30/// client uses to know which other methods are safe to call.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct HandshakeInfo {
33    pub app_name: String,
34    pub frust_version: String,
35    pub protocol_version: u32,
36    pub capabilities: Vec<Capability>,
37}
38
39/// A capability a server may declare at handshake, gating which other
40/// methods a client should expect to succeed (`screenshot` is the v1
41/// example — see [`crate::RpcError::NOT_SUPPORTED`]).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum Capability {
45    WidgetTree,
46    FrameStats,
47    Input,
48    Metrics,
49    Screenshot,
50    /// A capability name introduced by a newer protocol version than this
51    /// crate knows about. Deserializing an unrecognized capability must not
52    /// fail the whole handshake — forward compatibility, at the cost of not
53    /// round-tripping the original unrecognized name.
54    #[serde(other)]
55    Unknown,
56}
57
58/// `widget_tree` result. Nested (parent owns `children: Vec<WidgetNode>`)
59/// rather than a flat id-indexed list with parent pointers — the simplest
60/// v1 shape, and the one a debug client walks directly to render a tree
61/// view with no separate reconstruction pass.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct WidgetTreeDump {
64    pub roots: Vec<WidgetNode>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct WidgetNode {
69    pub id: u64,
70    pub type_name: String,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub debug_label: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub bounds: Option<RectPx>,
75    #[serde(default)]
76    pub children: Vec<WidgetNode>,
77}
78
79/// A logical-px rectangle — the same coordinate space every framework
80/// `InputEvent` uses (`docs/CODE_STANDARDS.md`'s Interaction Semantics:
81/// "Events are logical-coordinate by the time they cross `AppTree`").
82#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83pub struct RectPx {
84    pub x: f64,
85    pub y: f64,
86    pub width: f64,
87    pub height: f64,
88}
89
90/// `widget_props` request params.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub struct WidgetPropsParams {
93    pub id: u64,
94}
95
96/// `widget_props` result.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct WidgetProps {
99    pub id: u64,
100    pub entries: Vec<(String, String)>,
101}
102
103/// `frame_stats_subscribe` acknowledges with [`crate::AckResult`]; the
104/// server then pushes this payload as the `frame_stats` **notification**
105/// body on every subsequent frame.
106///
107/// Field names mirror `frust-shell-common::perf`'s `frust-perf raw` line
108/// verbatim (format v3: `acquire_us`/`submit_us`, superseding v2's single
109/// combined `present_us` — see that module's `format_raw_frame_line` doc)
110/// so tooling maps this payload onto the same field set 1:1, with no
111/// renaming step between the log-line parser and the wire type.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113pub struct FrameStats {
114    pub n: u64,
115    pub total_us: u64,
116    pub rebuild_us: u64,
117    pub layout_us: u64,
118    pub paint_us: u64,
119    pub encode_us: u64,
120    pub acquire_us: u64,
121    pub submit_us: u64,
122    pub skipped: bool,
123}
124
125/// `metrics_snapshot` result — v1 minimal (process RSS is best-effort/
126/// platform-dependent, hence optional; uptime is always known).
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub struct MetricsSnapshot {
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub rss_bytes: Option<u64>,
131    pub uptime_ms: u64,
132}
133
134/// `input_tap` request params (logical px, see [`RectPx`]'s doc).
135#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
136pub struct InputTapParams {
137    pub x: f64,
138    pub y: f64,
139}
140
141/// `input_scroll` request params (logical px; `dx`/`dy` are the scroll
142/// delta, same convention as a wheel/drag event elsewhere in the
143/// framework).
144#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
145pub struct InputScrollParams {
146    pub x: f64,
147    pub y: f64,
148    pub dx: f64,
149    pub dy: f64,
150}
151
152/// `input_text` request params.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct InputTextParams {
155    pub text: String,
156}
157
158/// The ack result `input_tap`/`input_scroll`/`input_text`/
159/// `frame_stats_subscribe` all reply with.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161pub struct AckResult {
162    #[serde(default = "default_true")]
163    pub ok: bool,
164}
165
166fn default_true() -> bool {
167    true
168}
169
170impl Default for AckResult {
171    fn default() -> Self {
172        Self { ok: true }
173    }
174}
175
176/// `screenshot` result. Declared in the protocol so the method/result shape
177/// exists for every client, but capability-gated in practice — a server
178/// with no [`Capability::Screenshot`] rejects the request with
179/// [`crate::RpcError::not_supported`] instead of ever returning this.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct ScreenshotResult {
182    pub png_base64: String,
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn handshake_params_round_trip_with_and_without_a_token() {
191        let with = HandshakeParams {
192            token: Some("0123456789abcdef".to_string()),
193        };
194        let json = serde_json::to_string(&with).unwrap();
195        assert_eq!(
196            serde_json::from_str::<HandshakeParams>(&json).unwrap(),
197            with
198        );
199
200        // Absent on the wire (an auth-off server, or a client that has no
201        // token to present) must decode, not fail.
202        let without: HandshakeParams = serde_json::from_str("{}").unwrap();
203        assert_eq!(without, HandshakeParams::default());
204        assert_eq!(without.token, None);
205        assert!(!serde_json::to_string(&without).unwrap().contains("token"));
206    }
207
208    #[test]
209    fn no_server_written_payload_carries_a_token_field() {
210        // The token is inbound-only: `handshake`'s *result* must never echo
211        // it back, or a log/transcript of the reply would leak the secret.
212        let info = HandshakeInfo {
213            app_name: "app".to_string(),
214            frust_version: "0.1.0".to_string(),
215            protocol_version: 1,
216            capabilities: vec![Capability::WidgetTree],
217        };
218        let json = serde_json::to_string(&info).unwrap();
219        assert!(!json.contains("token"));
220    }
221
222    #[test]
223    fn capability_round_trips() {
224        for cap in [
225            Capability::WidgetTree,
226            Capability::FrameStats,
227            Capability::Input,
228            Capability::Metrics,
229            Capability::Screenshot,
230        ] {
231            let json = serde_json::to_string(&cap).unwrap();
232            let back: Capability = serde_json::from_str(&json).unwrap();
233            assert_eq!(cap, back);
234        }
235    }
236
237    #[test]
238    fn unknown_capability_deserializes_to_unknown_variant() {
239        let cap: Capability = serde_json::from_str("\"some_future_capability\"").unwrap();
240        assert_eq!(cap, Capability::Unknown);
241    }
242
243    #[test]
244    fn ack_result_defaults_ok_true_when_field_omitted() {
245        let ack: AckResult = serde_json::from_str("{}").unwrap();
246        assert_eq!(ack, AckResult { ok: true });
247        assert_eq!(AckResult::default(), AckResult { ok: true });
248    }
249
250    #[test]
251    fn frame_stats_field_names_match_perf_raw_line() {
252        let stats = FrameStats {
253            n: 42,
254            total_us: 1_000,
255            rebuild_us: 100,
256            layout_us: 200,
257            paint_us: 300,
258            encode_us: 150,
259            acquire_us: 50,
260            submit_us: 200,
261            skipped: false,
262        };
263        let json = serde_json::to_value(stats).unwrap();
264        for key in [
265            "n",
266            "total_us",
267            "rebuild_us",
268            "layout_us",
269            "paint_us",
270            "encode_us",
271            "acquire_us",
272            "submit_us",
273            "skipped",
274        ] {
275            assert!(json.get(key).is_some(), "missing field: {key}");
276        }
277    }
278}