Skip to main content

client_core/
protocol.rs

1//! Wire protocol types shared across CLI, desktop, and the relay's WS frame.
2//!
3//! `Clip` and `DeviceInfo` are re-exported from the in-crate `proto` module,
4//! generated from `proto/cinch/v1/*.proto`. `WSMessage` and the action
5//! constants stay hand-written: the WebSocket envelope's "action + 8 optional
6//! siblings" shape doesn't map cleanly onto a proto oneof, and migrating it
7//! would change the WS wire format. That work is tracked separately.
8//!
9//! Action constants must match the Go relay verbatim (see `protocol/ws.go`
10//! Action* constants). Wire field names must not change without coordinated
11//! updates across all consumers.
12
13use serde::{Deserialize, Serialize};
14
15pub use crate::proto::cinch::v1::{Clip, Device as DeviceInfo};
16
17// WebSocket action constants (must match Go relay exactly).
18pub 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    // device_code_pending (relay → desktop) — push-approval notification
57    // for a remote machine that just initiated DeviceCodeStart. The
58    // existing `hostname` field above is reused to carry the requester's
59    // hostname.
60    #[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}