use serde::{Deserialize, Serialize};
pub use crate::proto::cinch::v1::{Clip, Device as DeviceInfo};
pub const ACTION_NEW_CLIP: &str = "new_clip";
pub const ACTION_CLIP_DELETED: &str = "clip_deleted";
pub const ACTION_SEND_CLIPBOARD: &str = "send_clipboard";
pub const ACTION_CLIPBOARD_CONTENT: &str = "clipboard_content";
pub const ACTION_PING: &str = "ping";
pub const ACTION_PONG: &str = "pong";
#[allow(dead_code)]
pub const ACTION_REVOKED: &str = "revoked";
#[allow(dead_code)]
pub const ACTION_TOKEN_ROTATED: &str = "token_rotated";
#[allow(dead_code)]
pub const ACTION_KEY_EXCHANGE_REQUESTED: &str = "key_exchange_requested";
#[allow(dead_code)]
pub const ACTION_CLIP_PINNED: &str = "clip_pinned";
#[allow(dead_code)]
pub const ACTION_DEVICE_CODE_PENDING: &str = "device_code_pending";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WSMessage {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub clip: Option<Clip>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pull_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_key_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_region: Option<String>,
}
impl WSMessage {
pub fn pong() -> Self {
Self {
action: ACTION_PONG.to_string(),
clip: None,
pull_id: None,
content: None,
error: None,
token: None,
device_id: None,
hostname: None,
reason: None,
device_key_fingerprint: None,
user_code: None,
requested_at: None,
source_region: None,
}
}
pub fn clipboard_content(pull_id: String, content: String) -> Self {
Self {
action: ACTION_CLIPBOARD_CONTENT.to_string(),
clip: None,
pull_id: Some(pull_id),
content: Some(content),
error: None,
token: None,
device_id: None,
hostname: None,
reason: None,
device_key_fingerprint: None,
user_code: None,
requested_at: None,
source_region: None,
}
}
pub fn clipboard_error(pull_id: String, err: String) -> Self {
Self {
action: ACTION_CLIPBOARD_CONTENT.to_string(),
clip: None,
pull_id: Some(pull_id),
content: None,
error: Some(err),
token: None,
device_id: None,
hostname: None,
reason: None,
device_key_fingerprint: None,
user_code: None,
requested_at: None,
source_region: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_new_clip_message() {
let json = r#"{
"action": "new_clip",
"clip": {
"clip_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"user_id": "user123",
"content": "hello world",
"content_type": "text",
"source": "remote:prod-api",
"label": "",
"byte_size": 11,
"created_at": "2026-04-14T12:00:00Z",
"ttl": 0
}
}"#;
let msg: WSMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.action, ACTION_NEW_CLIP);
let clip = msg.clip.unwrap();
assert_eq!(clip.clip_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert_eq!(clip.content, "hello world");
assert_eq!(clip.source, "remote:prod-api");
}
#[test]
fn test_parse_send_clipboard_message() {
let json = r#"{"action":"send_clipboard","pull_id":"pull123"}"#;
let msg: WSMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.action, ACTION_SEND_CLIPBOARD);
assert_eq!(msg.pull_id.unwrap(), "pull123");
}
#[test]
fn test_parse_ping_message() {
let json = r#"{"action":"ping"}"#;
let msg: WSMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.action, ACTION_PING);
}
#[test]
fn test_parse_clip_deleted_message() {
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"}}"#;
let msg: WSMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.action, ACTION_CLIP_DELETED);
assert_eq!(msg.clip.unwrap().clip_id, "del123");
}
#[test]
fn test_serialize_pong() {
let msg = WSMessage::pong();
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""action":"pong""#));
assert!(!json.contains("clip"));
}
#[test]
fn test_serialize_clipboard_content() {
let msg = WSMessage::clipboard_content("pull123".into(), "clipboard data".into());
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""action":"clipboard_content""#));
assert!(json.contains(r#""pull_id":"pull123""#));
assert!(json.contains(r#""content":"clipboard data""#));
}
}