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_SEND_CLIPBOARD: &str = "send_clipboard";
21pub const ACTION_CLIPBOARD_CONTENT: &str = "clipboard_content";
22pub const ACTION_PING: &str = "ping";
23pub const ACTION_PONG: &str = "pong";
24#[allow(dead_code)]
25pub const ACTION_REVOKED: &str = "revoked";
26#[allow(dead_code)]
27pub const ACTION_TOKEN_ROTATED: &str = "token_rotated";
28#[allow(dead_code)]
29pub const ACTION_KEY_EXCHANGE_REQUESTED: &str = "key_exchange_requested";
30#[allow(dead_code)]
31pub const ACTION_CLIP_PINNED: &str = "clip_pinned";
32#[allow(dead_code)]
33pub const ACTION_DEVICE_CODE_PENDING: &str = "device_code_pending";
34pub const ACTION_CLIENT_HELLO: &str = "client_hello";
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct WSMessage {
38 pub action: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub clip: Option<Clip>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub pull_id: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub content: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub error: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub token: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub device_id: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub hostname: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub reason: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub device_key_fingerprint: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub user_code: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub requested_at: Option<i64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub source_region: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub client_hello: Option<ClientHelloPayload>,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct ClientHelloPayload {
76 pub version: String,
77 #[serde(rename = "type")]
79 pub type_: String,
80 #[serde(default, skip_serializing_if = "String::is_empty")]
81 pub os: String,
82}
83
84impl WSMessage {
85 pub fn pong() -> Self {
86 Self {
87 action: ACTION_PONG.to_string(),
88 ..Default::default()
89 }
90 }
91
92 pub fn clipboard_content(pull_id: String, content: String) -> Self {
93 Self {
94 action: ACTION_CLIPBOARD_CONTENT.to_string(),
95 pull_id: Some(pull_id),
96 content: Some(content),
97 ..Default::default()
98 }
99 }
100
101 pub fn clipboard_error(pull_id: String, err: String) -> Self {
102 Self {
103 action: ACTION_CLIPBOARD_CONTENT.to_string(),
104 pull_id: Some(pull_id),
105 error: Some(err),
106 ..Default::default()
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn test_parse_new_clip_message() {
117 let json = r#"{
118 "action": "new_clip",
119 "clip": {
120 "clip_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
121 "user_id": "user123",
122 "content": "hello world",
123 "content_type": "text",
124 "source": "remote:prod-api",
125 "label": "",
126 "byte_size": 11,
127 "created_at": "2026-04-14T12:00:00Z",
128 "ttl": 0
129 }
130 }"#;
131 let msg: WSMessage = serde_json::from_str(json).unwrap();
132 assert_eq!(msg.action, ACTION_NEW_CLIP);
133 let clip = msg.clip.unwrap();
134 assert_eq!(clip.clip_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
135 assert_eq!(clip.content, "hello world");
136 assert_eq!(clip.source, "remote:prod-api");
137 }
138
139 #[test]
140 fn test_parse_send_clipboard_message() {
141 let json = r#"{"action":"send_clipboard","pull_id":"pull123"}"#;
142 let msg: WSMessage = serde_json::from_str(json).unwrap();
143 assert_eq!(msg.action, ACTION_SEND_CLIPBOARD);
144 assert_eq!(msg.pull_id.unwrap(), "pull123");
145 }
146
147 #[test]
148 fn test_parse_ping_message() {
149 let json = r#"{"action":"ping"}"#;
150 let msg: WSMessage = serde_json::from_str(json).unwrap();
151 assert_eq!(msg.action, ACTION_PING);
152 }
153
154 #[test]
155 fn test_parse_clip_deleted_message() {
156 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"}}"#;
157 let msg: WSMessage = serde_json::from_str(json).unwrap();
158 assert_eq!(msg.action, ACTION_CLIP_DELETED);
159 assert_eq!(msg.clip.unwrap().clip_id, "del123");
160 }
161
162 #[test]
163 fn test_serialize_pong() {
164 let msg = WSMessage::pong();
165 let json = serde_json::to_string(&msg).unwrap();
166 assert!(json.contains(r#""action":"pong""#));
167 assert!(!json.contains("clip"));
168 }
169
170 #[test]
171 fn test_serialize_clipboard_content() {
172 let msg = WSMessage::clipboard_content("pull123".into(), "clipboard data".into());
173 let json = serde_json::to_string(&msg).unwrap();
174 assert!(json.contains(r#""action":"clipboard_content""#));
175 assert!(json.contains(r#""pull_id":"pull123""#));
176 assert!(json.contains(r#""content":"clipboard data""#));
177 }
178}