Skip to main content

incular_devtools_protocol/
lib.rs

1//! Typed, versioned wire protocol between a running Incular application (the
2//! *target*) and Incular DevTools.
3//!
4//! This crate intentionally depends only on `serde`: it must stay usable by
5//! the standalone DevTools UI, the in-target agent, and offline tooling
6//! without pulling in the framework runtime or renderer.
7
8use serde::{Deserialize, Serialize};
9
10/// Oldest wire protocol that remains compatible with this implementation.
11/// Protocol 2 only adds typed property-edit requests, so protocol 1 clients
12/// can continue to inspect a protocol 2 target.
13pub const MIN_SUPPORTED_PROTOCOL_VERSION: u32 = 1;
14
15/// Current wire protocol version.
16pub const PROTOCOL_VERSION: u32 = 2;
17
18/// DevTools implementation version carried in the handshake for diagnostics.
19pub const DEVTOOLS_VERSION: &str = env!("CARGO_PKG_VERSION");
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum PeerKind {
24    Target,
25    Devtools,
26}
27
28/// First message sent by either side after the WebSocket opens. The target
29/// validates `auth_token` against its random session token before accepting
30/// any further frame.
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Hello {
33    pub protocol_version: u32,
34    pub devtools_version: String,
35    pub kind: PeerKind,
36    pub auth_token: String,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum ErrorCode {
42    ProtocolVersionMismatch,
43    Unauthorized,
44    UnknownId,
45    StaleId,
46    Unsupported,
47    InvalidRequest,
48    InternalError,
49}
50
51// ---------------------------------------------------------------------------
52// Opaque identifiers: raw arena indices and pointers never cross the wire.
53// The generation field lets targets reject ids whose retained object was
54// destroyed and whose slot was reused.
55// ---------------------------------------------------------------------------
56
57macro_rules! opaque_id {
58    ($name:ident) => {
59        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
60        pub struct $name {
61            index: u64,
62            generation: u64,
63        }
64        impl $name {
65            pub const fn new(index: u64, generation: u64) -> Self {
66                Self { index, generation }
67            }
68            pub const fn index(&self) -> u64 {
69                self.index
70            }
71            pub const fn generation(&self) -> u64 {
72                self.generation
73            }
74        }
75        impl std::fmt::Display for $name {
76            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77                write!(
78                    f,
79                    "{}:{}:{}",
80                    stringify!($name),
81                    self.index,
82                    self.generation
83                )
84            }
85        }
86    };
87}
88
89opaque_id!(DevWindowId);
90opaque_id!(DevWidgetId);
91opaque_id!(DevRenderId);
92opaque_id!(DevLayerId);
93opaque_id!(DevSemanticsId);
94opaque_id!(DevSignalId);
95
96// ---------------------------------------------------------------------------
97// Structured diagnostic values (closed set; no arbitrary JSON).
98// ---------------------------------------------------------------------------
99
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum DebugValue {
103    Bool(bool),
104    Int(i64),
105    Uint(u64),
106    Float(f64),
107    /// Truncated string content; sensitive sources become [`Self::Redacted`].
108    Str(String),
109    Color(u8, u8, u8, u8),
110    Enum(String),
111    Size([f32; 2]),
112    Offset([f32; 2]),
113    Rect([f32; 4]),
114    Insets([f32; 4]),
115    Constraints {
116        min_width: f32,
117        /// JSON `null` represents an unbounded maximum, including in messages
118        /// emitted by older targets through serde's non-finite float encoding.
119        #[serde(with = "unbounded_maximum")]
120        max_width: f32,
121        min_height: f32,
122        #[serde(with = "unbounded_maximum")]
123        max_height: f32,
124    },
125    Optional(Option<Box<DebugValue>>),
126    List(Vec<DebugValue>),
127    Redacted,
128}
129
130mod unbounded_maximum {
131    use serde::{Deserialize, Deserializer, Serializer, ser::Error};
132
133    pub fn serialize<S: Serializer>(value: &f32, serializer: S) -> Result<S::Ok, S::Error> {
134        if value.is_finite() {
135            serializer.serialize_f32(*value)
136        } else if *value == f32::INFINITY {
137            serializer.serialize_none()
138        } else {
139            Err(S::Error::custom(
140                "constraint maximum must be finite or positive infinity",
141            ))
142        }
143    }
144
145    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f32, D::Error> {
146        Option::<f32>::deserialize(deserializer).map(|maximum| maximum.unwrap_or(f32::INFINITY))
147    }
148}
149
150#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
151pub struct DebugProperty {
152    pub name: String,
153    pub value: DebugValue,
154    /// `DEV OVERRIDE` active for this property.
155    #[serde(default)]
156    pub overridden: bool,
157    #[serde(default)]
158    pub editable: bool,
159}
160
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
162pub struct PropertyChange {
163    pub name: String,
164    pub old: Option<DebugValue>,
165    pub new: Option<DebugValue>,
166}
167
168#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case", tag = "kind")]
170pub enum InvalidationReason {
171    SignalWrite {
172        signal: DevSignalId,
173        name: String,
174        old: Option<String>,
175        new: Option<String>,
176    },
177    ParentReconciliation {
178        changed: Vec<PropertyChange>,
179    },
180    WidgetConfigurationChanged,
181    EnvironmentChanged {
182        field: String,
183        old: Option<String>,
184        new: Option<String>,
185    },
186    LocaleChanged,
187    WindowMetricsChanged,
188    ConstraintsChanged,
189    Animation,
190    TaskCompletion,
191    Navigation,
192    Restoration,
193    ManualInvalidation,
194    Mounted,
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
198pub struct SourceLocation {
199    pub file: String,
200    pub line: u32,
201    pub column: u32,
202}
203
204// ---------------------------------------------------------------------------
205// Widget tree snapshot + incremental deltas.
206// ---------------------------------------------------------------------------
207
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209pub struct WidgetNode {
210    pub id: DevWidgetId,
211    pub parent: Option<DevWidgetId>,
212    pub type_name: String,
213    pub key: Option<String>,
214    /// Short label for Text-like leaves (truncated; sensitive → redacted).
215    pub label: Option<String>,
216    pub child_ids: Vec<DevWidgetId>,
217    /// Bumped whenever inspected configuration changes; deltas compare this
218    /// instead of resending unchanged subtrees.
219    pub revision: u64,
220}
221
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case", tag = "kind")]
224pub enum TreeDelta {
225    Snapshot {
226        window: DevWindowId,
227        root: Box<WidgetNode>,
228        nodes: Vec<WidgetNode>,
229        truncated: bool,
230    },
231    Insert {
232        node: Box<WidgetNode>,
233    },
234    Remove {
235        id: DevWidgetId,
236    },
237    Move {
238        id: DevWidgetId,
239        parent: DevWidgetId,
240        position: usize,
241    },
242    Update {
243        node: Box<WidgetNode>,
244    },
245}
246
247// ---------------------------------------------------------------------------
248// Details, telemetry, signals.
249// ---------------------------------------------------------------------------
250
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
252pub struct ElementState {
253    pub mount_generation: u64,
254    pub builds: u64,
255    pub layouts: u64,
256    pub paints: u64,
257    pub composites: u64,
258    pub semantic_updates: u64,
259    pub constraints: Option<DebugValue>,
260    pub size: Option<[f32; 2]>,
261    pub offset: Option<[f32; 2]>,
262    pub world_bounds: Option<[f32; 4]>,
263    /// Resolved retained baseline, when this render object establishes one.
264    #[serde(default)]
265    pub baseline: Option<f32>,
266    /// The actual rectangular clip used by a retained clip layer, expressed
267    /// in the same target coordinate space as `world_bounds`.
268    #[serde(default)]
269    pub clip: Option<[f32; 4]>,
270}
271
272/// Read-only geometry captured from the retained layout result.  This is a
273/// snapshot, never a handle to a mutable render object; DevTools must not run
274/// layout again merely to answer an inspection request.
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
276pub struct LayoutInspection {
277    pub node: DevWidgetId,
278    pub parent: Option<DevWidgetId>,
279    pub incoming_constraints: Option<DebugValue>,
280    pub resolved_size: [f32; 2],
281    pub local_offset: [f32; 2],
282    /// Kurbo affine coefficients `[xx, yx, xy, yy, dx, dy]`.
283    pub local_transform: [f32; 6],
284    /// Retained world transform, expressed using Kurbo's coefficients.
285    pub world_transform: [f32; 6],
286    pub world_bounds: [f32; 4],
287    pub content_bounds: [f32; 4],
288    pub padding: Option<[f32; 4]>,
289    pub clip: Option<[f32; 4]>,
290    pub baseline: Option<f32>,
291    pub details: LayoutDetails,
292}
293
294#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
295pub struct LayoutHistoryEntry {
296    pub sequence: u64,
297    pub old_constraints: Option<DebugValue>,
298    pub new_constraints: DebugValue,
299    pub old_size: [f32; 2],
300    pub new_size: [f32; 2],
301    pub cause: Option<String>,
302}
303
304#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
305pub struct WorkReasons {
306    pub layout: Option<String>,
307    pub paint: Option<String>,
308    pub composite: Option<String>,
309}
310
311/// Layout-strategy-specific, compact inspection data.  Every coordinate and
312/// allocation comes from the completed retained layout pass; the DevTools UI
313/// only visualizes this data.
314#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case", tag = "kind")]
316pub enum LayoutDetails {
317    Box,
318    Flex {
319        axis: String,
320        available_main: Option<f32>,
321        non_flex_extent: f32,
322        flexible_extent: f32,
323        used_extent: f32,
324        remaining_extent: f32,
325        overflow: f32,
326        children: Vec<FlexChildInspection>,
327    },
328    Stack {
329        alignment: [f32; 2],
330        indexed_active: Option<usize>,
331        children: Vec<StackChildInspection>,
332    },
333    Positioned {
334        left: Option<f32>,
335        right: Option<f32>,
336        top: Option<f32>,
337        bottom: Option<f32>,
338        width: Option<f32>,
339        height: Option<f32>,
340    },
341    Transform {
342        matrix: [f32; 6],
343        determinant: f32,
344        invertible: bool,
345    },
346    Fitted {
347        fit: String,
348        alignment: [f32; 2],
349        source_size: Option<[f32; 2]>,
350        destination_size: [f32; 2],
351        matrix: [f32; 6],
352        determinant: f32,
353        invertible: bool,
354    },
355    Scroll {
356        axis: String,
357        viewport_extent: f32,
358        content_extent: f32,
359        offset: f32,
360        min_scroll: f32,
361        max_scroll: f32,
362    },
363    LazyViewport {
364        item_count: usize,
365        materialized_start: usize,
366        materialized_end: usize,
367        materialized_items: usize,
368        viewport_extent: f32,
369        cache_extent: f32,
370        scroll_offset: f32,
371    },
372    Text {
373        text_length: usize,
374        max_lines: Option<usize>,
375        overflow: String,
376        line_count: Option<usize>,
377    },
378    Custom {
379        layout_kind: String,
380    },
381}
382
383#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
384pub struct FlexChildInspection {
385    pub node: DevWidgetId,
386    pub index: usize,
387    pub type_name: String,
388    pub size: [f32; 2],
389    pub offset: [f32; 2],
390    pub flex: Option<u32>,
391    pub fit: Option<String>,
392    /// The actual max constraint delivered to this child in the main axis.
393    pub allocated_main_extent: Option<f32>,
394    pub actual_main_extent: f32,
395    pub cross_extent: f32,
396}
397
398#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
399pub struct StackChildInspection {
400    pub node: DevWidgetId,
401    pub index: usize,
402    pub type_name: String,
403    pub bounds: [f32; 4],
404    pub painted: bool,
405    pub left: Option<f32>,
406    pub right: Option<f32>,
407    pub top: Option<f32>,
408    pub bottom: Option<f32>,
409    pub width: Option<f32>,
410    pub height: Option<f32>,
411}
412
413#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
414pub struct NodeDetails {
415    pub id: DevWidgetId,
416    pub window: DevWindowId,
417    pub type_name: String,
418    pub key: Option<String>,
419    pub state: ElementState,
420    /// Exact retained layout state for the selected node.  This is optional
421    /// only for compatibility with targets that cannot expose a live render.
422    #[serde(default)]
423    pub layout: Option<LayoutInspection>,
424    /// Last relevant retained geometry changes, populated only while Deep
425    /// capture is active and bounded by the target.
426    #[serde(default)]
427    pub layout_history: Vec<LayoutHistoryEntry>,
428    #[serde(default)]
429    pub work_reasons: WorkReasons,
430    pub properties: Vec<DebugProperty>,
431    /// Bounded diff captured from the last compatible widget configuration
432    /// update. It uses only curated inspectable properties.
433    #[serde(default)]
434    pub property_changes: Vec<PropertyChange>,
435    /// Bounded coalesced invalidations observed before this selected node's
436    /// next BUILD. `invalidation` remains the latest cause for compatibility.
437    #[serde(default)]
438    pub invalidation_causes: Vec<InvalidationReason>,
439    /// Debug-visible signals this live element actually consumed during its
440    /// current reactive subscription. Stale/unmounted entries are omitted.
441    #[serde(default)]
442    pub consumed_signals: Vec<DevSignalId>,
443    pub invalidation: Option<InvalidationReason>,
444    pub render: Option<DevRenderId>,
445    pub semantics: Option<DevSemanticsId>,
446    pub source: Option<SourceLocation>,
447}
448
449#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
450pub struct FrameTimingsWire {
451    pub event_processing: u32,
452    pub runtime_messages: u32,
453    pub build: u32,
454    pub layout: u32,
455    pub composite: u32,
456    pub semantics: u32,
457    pub paint: u32,
458    pub cpu_total: u32,
459    pub prepare: u32,
460    pub encode: u32,
461    pub submit: u32,
462    /// GPU main-pass time in microseconds when timestamp queries resolve.
463    pub gpu_us: Option<f64>,
464}
465
466#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
467pub struct FrameRecordEvent {
468    pub window: DevWindowId,
469    pub frame: u64,
470    pub timings: FrameTimingsWire,
471    /// Actual monitor-derived CPU frame budget when the platform exposes a
472    /// refresh rate. `None` means the target cannot report one.
473    #[serde(default)]
474    pub budget_us: Option<u32>,
475    pub over_budget: bool,
476    pub draw_calls: u32,
477    pub instances: u32,
478    pub upload_bytes: u64,
479    pub pipelines_created: u32,
480}
481
482#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "snake_case")]
484pub enum DevtoolsProfilerMode {
485    #[default]
486    Basic,
487    Performance,
488    Deep,
489}
490
491#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
492#[serde(rename_all = "snake_case")]
493pub enum TracePhase {
494    Build,
495    Layout,
496    Paint,
497    Semantics,
498    Composite,
499}
500
501/// One real, hierarchical per-node timing recorded only in Deep mode.
502#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
503pub struct TraceEvent {
504    pub node: DevWidgetId,
505    pub phase: TracePhase,
506    pub parent: Option<u32>,
507    pub start_us: u32,
508    pub duration_us: u32,
509}
510
511/// Bounded trace batch for one target/window frame. `truncated` and
512/// `dropped_events` make partial data impossible to mistake for a full trace.
513#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
514pub struct DeepFrameTrace {
515    pub window: DevWindowId,
516    pub frame: u64,
517    pub events: Vec<TraceEvent>,
518    pub truncated: bool,
519    pub dropped_events: u32,
520}
521
522#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
523pub struct ResourceCounts {
524    pub elements: usize,
525    pub render_objects: usize,
526    pub layers: usize,
527    pub semantics_nodes: usize,
528    pub signals: usize,
529    pub tasks_active: usize,
530    pub glyph_atlas_pages: usize,
531    pub image_resources: usize,
532    pub gradient_resources: usize,
533    pub path_meshes: usize,
534    pub offscreen_bytes: usize,
535    pub effect_cached_bytes: usize,
536    pub rss_mb: usize,
537}
538
539#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
540pub struct MemorySnapshot {
541    pub label: String,
542    pub counts: ResourceCounts,
543}
544
545#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
546pub struct SignalSummary {
547    pub id: DevSignalId,
548    pub name: Option<String>,
549    pub type_name: String,
550    /// Monotonic value generation; increments only after a real signal write.
551    #[serde(default)]
552    pub generation: u64,
553    pub write_count: u64,
554    pub subscriber_count: usize,
555    pub last_write_summary: Option<String>,
556    pub editable: bool,
557}
558
559#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
560pub struct SignalSubscriber {
561    pub signal: DevSignalId,
562    pub element: DevWidgetId,
563    pub path: String,
564}
565
566#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
567#[serde(rename_all = "snake_case")]
568pub enum DebugOption {
569    LayoutBounds,
570    LayoutBoundsSubtree,
571    LayoutBoundsWholeWindow,
572    PaddingContent,
573    Baselines,
574    Clips,
575    HitTestRegions,
576    SemanticsBounds,
577    ScrollViewports,
578    LayerBoundaries,
579    RepaintRainbow,
580    FlashRebuilds,
581    HighlightBuild,
582    HighlightLayout,
583    HighlightPaint,
584    HighlightSemantics,
585    HighlightComposite,
586    HighlightOversizedImages,
587}
588
589#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(rename_all = "snake_case")]
591pub enum CacheKind {
592    TextLayouts,
593    GlyphAtlas,
594    Images,
595    Paths,
596    Gradients,
597    Offscreen,
598}
599
600#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
601#[serde(rename_all = "snake_case")]
602pub enum EditableValue {
603    Bool(bool),
604    Int(i64),
605    Uint(u64),
606    Float(f64),
607    Str(String),
608    Color(u8, u8, u8, u8),
609}
610
611#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
612pub struct CacheReport {
613    pub which: CacheKind,
614    pub entries: usize,
615    pub hits: u64,
616    pub misses: u64,
617}
618
619// ---------------------------------------------------------------------------
620// Envelope: requests, responses, target events.
621// ---------------------------------------------------------------------------
622
623/// DevTools → target. Every request produces exactly one
624/// [`ResponsePayload`] correlated by `request_id`.
625#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
626#[serde(rename_all = "snake_case", tag = "method")]
627pub enum RequestMethod {
628    GetTargetInfo,
629    GetWidgetTree {
630        window: DevWindowId,
631    },
632    GetNodeDetails {
633        id: DevWidgetId,
634    },
635    EditProperty {
636        id: DevWidgetId,
637        name: String,
638        value: DebugValue,
639    },
640    StartInspectMode {
641        window: DevWindowId,
642    },
643    StopInspectMode {
644        window: DevWindowId,
645    },
646    HighlightNode {
647        window: DevWindowId,
648        id: Option<DevWidgetId>,
649    },
650    SetDebugOption {
651        name: DebugOption,
652        enabled: bool,
653    },
654    SetProfilerMode {
655        mode: DevtoolsProfilerMode,
656    },
657    StartRecording,
658    StopRecording,
659    TakeMemorySnapshot {
660        label: String,
661    },
662    ListSignals,
663    GetSignalSubscribers {
664        id: DevSignalId,
665    },
666    EditSignal {
667        id: DevSignalId,
668        value: EditableValue,
669    },
670    ResetOverrides,
671    ClearCache {
672        which: CacheKind,
673    },
674    SetAnimationSpeed {
675        scale: f32,
676    },
677}
678
679#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
680#[serde(rename_all = "snake_case", tag = "result")]
681pub enum ResponsePayload {
682    Ok,
683    TargetInfo(Box<TargetInfo>),
684    WidgetTree {
685        deltas: Vec<TreeDelta>,
686        tree_revision: u64,
687    },
688    NodeDetails(Box<NodeDetails>),
689    RecordingStarted,
690    RecordingStopped {
691        frames: u64,
692    },
693    MemorySnapshot(MemorySnapshot),
694    Signals {
695        signals: Vec<SignalSummary>,
696    },
697    SignalSubscribers {
698        subscribers: Vec<SignalSubscriber>,
699    },
700    Edited,
701    OverridesReset,
702    CacheCleared(CacheReport),
703    AnimationSpeedSet(f32),
704    ProfilerModeSet(DevtoolsProfilerMode),
705    Error {
706        code: ErrorCode,
707        message: String,
708    },
709}
710
711/// Target → DevTools unsolicited events (bounded queues apply upstream).
712#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
713#[serde(rename_all = "snake_case", tag = "event")]
714pub enum TargetEvent {
715    /// Incremental retained-tree update for a window that the client has
716    /// explicitly inspected. Targets never serialize these while no client
717    /// has subscribed to a tree.
718    WidgetTreeDeltas {
719        window: DevWindowId,
720        deltas: Vec<TreeDelta>,
721        tree_revision: u64,
722    },
723    FrameRecord(FrameRecordEvent),
724    DeepTrace(DeepFrameTrace),
725    WidgetSelectedByUser {
726        window: DevWindowId,
727        id: DevWidgetId,
728    },
729    Log {
730        level: String,
731        target: String,
732        message: String,
733    },
734    WindowsChanged,
735    DroppedTelemetry {
736        count: u64,
737    },
738    InspectModeEnded {
739        window: DevWindowId,
740    },
741}
742
743/// One wire frame after the handshake.
744#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
745#[serde(rename_all = "snake_case", tag = "type")]
746pub enum Message {
747    Request {
748        request_id: u64,
749        body: RequestMethod,
750    },
751    Response {
752        request_id: u64,
753        payload: Result<ResponsePayload, ErrorCode>,
754    },
755    Event(TargetEvent),
756    /// Human-readable rejection during handshake; the peer closes after.
757    Rejection {
758        code: ErrorCode,
759        message: String,
760    },
761}
762
763#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
764pub struct TargetInfo {
765    pub protocol_version: u32,
766    pub framework_version: String,
767    pub pid: u32,
768    pub executable: String,
769    pub platform: String,
770    pub windows: Vec<WindowSummary>,
771}
772
773#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
774pub struct WindowSummary {
775    pub id: DevWindowId,
776    pub title: String,
777    pub logical_size: [f32; 2],
778    pub scale_factor: f64,
779}
780
781/// Per-user discovery record written by devtools-enabled targets so the
782/// DevTools launcher can list running applications without port scanning.
783#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
784pub struct DiscoveryRecord {
785    pub pid: u32,
786    pub app_name: String,
787    pub port: u16,
788    /// Session token; the discovery file lives in a user-only directory, and
789    /// DevTools still authenticates over the socket before any data flows.
790    pub auth_token: String,
791    pub started_unix_ms: u64,
792    pub protocol_version: u32,
793}
794
795/// Validates a handshake from the *other* side. Targets call this with the
796/// client hello (expecting [`PeerKind::Devtools`]) and vice versa.
797pub fn check_hello(
798    hello: &Hello,
799    expected_kind: PeerKind,
800    expected_token: Option<&str>,
801) -> Result<(), ErrorCode> {
802    if !(MIN_SUPPORTED_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&hello.protocol_version) {
803        return Err(ErrorCode::ProtocolVersionMismatch);
804    }
805    if let Some(token) = expected_token {
806        // Constant-time-ish comparison: equal-length early exit leaks only
807        // length, which the wire already reveals.
808        if !token_eq(&hello.auth_token, token) {
809            return Err(ErrorCode::Unauthorized);
810        }
811    }
812    if hello.kind != expected_kind {
813        return Err(ErrorCode::InvalidRequest);
814    }
815    Ok(())
816}
817
818fn token_eq(a: &str, b: &str) -> bool {
819    let (a, b) = (a.as_bytes(), b.as_bytes());
820    if a.len() != b.len() {
821        return false;
822    }
823    let mut diff = 0u8;
824    for (x, y) in a.iter().zip(b.iter()) {
825        diff |= x ^ y;
826    }
827    diff == 0
828}