cinchcli-core 0.1.5

Shared client-side primitives for Cinch (cinchcli.com): generated wire DTOs, REST/WebSocket clients, AES-256-GCM + X25519 crypto, credential storage, local SQLite store, and sync helpers.
Documentation
//! Wire protocol types shared across CLI, desktop, and the relay's WS frame.
//!
//! `Clip` and `DeviceInfo` are re-exported from the in-crate `proto` module,
//! generated from `proto/cinch/v1/*.proto`. `WSMessage` and the action
//! constants stay hand-written: the WebSocket envelope's "action + 8 optional
//! siblings" shape doesn't map cleanly onto a proto oneof, and migrating it
//! would change the WS wire format. That work is tracked separately.
//!
//! Action constants must match the Go relay verbatim (see `protocol/ws.go`
//! Action* constants). Wire field names must not change without coordinated
//! updates across all consumers.

use serde::{Deserialize, Serialize};

pub use crate::proto::cinch::v1::{Clip, Device as DeviceInfo};

// WebSocket action constants (must match Go relay exactly).
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>,
    // device_code_pending (relay → desktop) — push-approval notification
    // for a remote machine that just initiated DeviceCodeStart. The
    // existing `hostname` field above is reused to carry the requester's
    // hostname.
    #[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""#));
    }
}