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