Skip to main content

devicerail_protocol/
device.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use uuid::Uuid;
6
7use crate::{UiSnapshotOmissionReason, UiSnapshotRef};
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
11#[serde(transparent)]
12pub struct DeviceId(pub String);
13
14impl DeviceId {
15    pub fn new(value: impl Into<String>) -> Self {
16        Self(value.into())
17    }
18}
19
20impl fmt::Display for DeviceId {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        self.0.fmt(formatter)
23    }
24}
25
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
28#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
29pub enum Platform {
30    Web,
31    Android,
32    Ios,
33    HarmonyOs,
34    MacOs,
35    Windows,
36    Linux,
37    Rdp,
38    Mock,
39    Other(String),
40}
41
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct DeviceInfo {
46    pub id: DeviceId,
47    pub name: String,
48    pub platform: Platform,
49    pub os_version: Option<String>,
50    pub connected: bool,
51}
52
53/// Result returned by `devices.list`.
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct DevicesListResult {
58    pub devices: Vec<DeviceInfo>,
59    pub selected_device_id: Option<DeviceId>,
60}
61
62/// Parameters accepted by `device.select`.
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct DeviceSelectParams {
67    pub device_id: DeviceId,
68}
69
70/// Result returned by `device.select`.
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct DeviceSelectResult {
75    pub device: DeviceInfo,
76}
77
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct Viewport {
82    #[cfg_attr(feature = "schema", schemars(range(max = 4_294_967_295_u64)))]
83    pub width: u32,
84    #[cfg_attr(feature = "schema", schemars(range(max = 4_294_967_295_u64)))]
85    pub height: u32,
86    pub scale_factor: f64,
87}
88
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct AssetRef {
93    pub id: String,
94    pub media_type: String,
95    pub uri: String,
96    pub sha256: Option<String>,
97}
98
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub enum ScreenshotOmissionReason {
103    Policy,
104    ProtectedAction,
105}
106
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
109#[serde(rename_all = "camelCase")]
110pub struct Observation {
111    pub id: Uuid,
112    pub device_id: DeviceId,
113    #[serde(
114        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
115        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
116    )]
117    #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
118    pub captured_at_ms: u64,
119    pub viewport: Viewport,
120    pub screenshot: Option<AssetRef>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub screenshot_omission: Option<ScreenshotOmissionReason>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub ui_snapshot: Option<UiSnapshotRef>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
127    #[serde(default)]
128    pub metadata: Map<String, Value>,
129}
130
131impl Observation {
132    /// Returns every typed Evidence reference owned by this Observation.
133    pub fn asset_refs(&self) -> impl Iterator<Item = &AssetRef> {
134        self.screenshot
135            .iter()
136            .chain(self.ui_snapshot.iter().map(|snapshot| &snapshot.evidence))
137    }
138
139    /// A snapshot and its omission reason are mutually exclusive. Both absent
140    /// preserves the pre-1.5 Observation shape and represents no claim.
141    pub const fn ui_snapshot_state_is_valid(&self) -> bool {
142        !(self.ui_snapshot.is_some() && self.ui_snapshot_omission.is_some())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use serde_json::{Map, json};
149    use uuid::Uuid;
150
151    use super::{
152        DeviceId, DeviceSelectParams, DeviceSelectResult, DevicesListResult, Observation,
153        ScreenshotOmissionReason, Viewport,
154    };
155
156    #[test]
157    fn device_ids_have_stable_lexical_order() {
158        let mut ids = [
159            DeviceId::new("web-z"),
160            DeviceId::new("android-2"),
161            DeviceId::new("android-10"),
162        ];
163        ids.sort();
164
165        assert_eq!(ids.map(|id| id.0), ["android-10", "android-2", "web-z"]);
166    }
167
168    #[test]
169    fn routing_models_are_strict_and_use_camel_case() {
170        let params: DeviceSelectParams = serde_json::from_value(json!({
171            "deviceId": "android-emulator-5554"
172        }))
173        .expect("select params");
174        assert_eq!(params.device_id, DeviceId::new("android-emulator-5554"));
175        assert!(
176            serde_json::from_value::<DeviceSelectParams>(json!({
177                "deviceId": "android-emulator-5554",
178                "unknown": true
179            }))
180            .is_err()
181        );
182
183        let list: DevicesListResult = serde_json::from_value(json!({
184            "devices": [],
185            "selectedDeviceId": null
186        }))
187        .expect("list result");
188        assert!(list.devices.is_empty());
189        assert!(list.selected_device_id.is_none());
190        assert_eq!(
191            serde_json::to_value(list).expect("serialize list result"),
192            json!({ "devices": [], "selectedDeviceId": null })
193        );
194
195        assert!(
196            serde_json::from_value::<DeviceSelectResult>(json!({
197                "device": {
198                    "id": "android-emulator-5554",
199                    "name": "Pixel",
200                    "platform": { "kind": "android" },
201                    "osVersion": "15",
202                    "connected": true
203                },
204                "selectedDeviceId": "android-emulator-5554"
205            }))
206            .is_err()
207        );
208    }
209
210    #[test]
211    fn screenshot_omission_is_optional_and_typed() {
212        let base = Observation {
213            id: Uuid::nil(),
214            device_id: DeviceId::new("mock-1"),
215            captured_at_ms: 1,
216            viewport: Viewport {
217                width: 1,
218                height: 1,
219                scale_factor: 1.0,
220            },
221            screenshot: None,
222            screenshot_omission: None,
223            ui_snapshot: None,
224            ui_snapshot_omission: None,
225            metadata: Map::new(),
226        };
227        let legacy = serde_json::to_value(&base).expect("legacy observation");
228        assert!(legacy.get("screenshotOmission").is_none());
229        assert!(legacy.get("uiSnapshot").is_none());
230        assert!(legacy.get("uiSnapshotOmission").is_none());
231
232        let omitted = Observation {
233            screenshot_omission: Some(ScreenshotOmissionReason::ProtectedAction),
234            ..base
235        };
236        assert_eq!(
237            serde_json::to_value(omitted).expect("omitted observation")["screenshotOmission"],
238            "protectedAction"
239        );
240    }
241}