1use serde::{Deserialize, Serialize};
14
15pub use crate::proto::cinch::v1::{Clip, Device as DeviceInfo};
16
17pub const ACTION_NEW_CLIP: &str = "new_clip";
19pub const ACTION_CLIP_DELETED: &str = "clip_deleted";
20pub const ACTION_PING: &str = "ping";
21pub const ACTION_PONG: &str = "pong";
22#[allow(dead_code)]
23pub const ACTION_REVOKED: &str = "revoked";
24#[allow(dead_code)]
25pub const ACTION_TOKEN_ROTATED: &str = "token_rotated";
26#[allow(dead_code)]
27pub const ACTION_KEY_EXCHANGE_REQUESTED: &str = "key_exchange_requested";
28#[allow(dead_code)]
29pub const ACTION_CLIP_PINNED: &str = "clip_pinned";
30#[allow(dead_code)]
31pub const ACTION_DEVICE_CODE_PENDING: &str = "device_code_pending";
32pub const ACTION_CLIENT_HELLO: &str = "client_hello";
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct WSMessage {
36 pub action: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub clip: Option<Clip>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub token: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub device_id: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub hostname: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub reason: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub device_key_fingerprint: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub user_code: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub requested_at: Option<i64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub source_region: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub client_hello: Option<ClientHelloPayload>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct ClientHelloPayload {
68 pub version: String,
69 #[serde(rename = "type")]
71 pub type_: String,
72 #[serde(default, skip_serializing_if = "String::is_empty")]
73 pub os: String,
74}
75
76impl WSMessage {
77 pub fn pong() -> Self {
78 Self {
79 action: ACTION_PONG.to_string(),
80 ..Default::default()
81 }
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn test_parse_new_clip_message() {
91 let json = r#"{
92 "action": "new_clip",
93 "clip": {
94 "clip_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
95 "user_id": "user123",
96 "content": "hello world",
97 "content_type": "text",
98 "source": "remote:prod-api",
99 "label": "",
100 "byte_size": 11,
101 "created_at": "2026-04-14T12:00:00Z",
102 "ttl": 0
103 }
104 }"#;
105 let msg: WSMessage = serde_json::from_str(json).unwrap();
106 assert_eq!(msg.action, ACTION_NEW_CLIP);
107 let clip = msg.clip.unwrap();
108 assert_eq!(clip.clip_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
109 assert_eq!(clip.content, "hello world");
110 assert_eq!(clip.source, "remote:prod-api");
111 }
112
113 #[test]
114 fn test_parse_ping_message() {
115 let json = r#"{"action":"ping"}"#;
116 let msg: WSMessage = serde_json::from_str(json).unwrap();
117 assert_eq!(msg.action, ACTION_PING);
118 }
119
120 #[test]
121 fn test_parse_clip_deleted_message() {
122 let json = r#"{"action":"clip_deleted","clip":{"clip_id":"del123","user_id":"u1","content":"","content_type":"text","source":"local","created_at":"2026-04-14T12:00:00Z"}}"#;
123 let msg: WSMessage = serde_json::from_str(json).unwrap();
124 assert_eq!(msg.action, ACTION_CLIP_DELETED);
125 assert_eq!(msg.clip.unwrap().clip_id, "del123");
126 }
127
128 #[test]
129 fn test_serialize_pong() {
130 let msg = WSMessage::pong();
131 let json = serde_json::to_string(&msg).unwrap();
132 assert!(json.contains(r#""action":"pong""#));
133 assert!(!json.contains("clip"));
134 }
135}