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";
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    // device_code_pending (relay → desktop) — push-approval notification
58    // for a remote machine that just initiated DeviceCodeStart. The
59    // existing `hostname` field above is reused to carry the requester's
60    // hostname.
61    #[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    // client_hello (client → relay) — sent immediately after WS auth so
68    // the relay can record the client's self-reported version and OS.
69    // See `version::ClientInfo::client_hello_message`.
70    #[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    // "type" is a Rust reserved keyword; serialized as "type" via serde rename.
78    #[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}