cinchcli-core 0.1.0

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
//! Local-clip ingest path.
//!
//! Captures clips detected on the local clipboard, encrypts them, pushes to
//! the relay, then write-throughs to the shared store using the relay-assigned
//! clip ID. Mirrors the `cinch push` flow so the desktop and CLI converge on a
//! single push pipeline.
//!
//! The relay is the source of truth for clip IDs; we always wait for the push
//! response before writing locally so the local row carries the same ULID the
//! WS broadcast will emit. This avoids a duplicate row when the broadcast loops
//! back to this device.
//!
//! E2EE is mandatory: callers must supply an encryption key. With no key the
//! push is rejected (`IngestError::NoEncryptionKey`); we never store plaintext
//! in the unencrypted-clip path because the receiver-side store would reject
//! it on the next backfill anyway.

use std::sync::Arc;

use crate::crypto;
use crate::http::{HttpError, RestClient};
use crate::rest::{ContentType, PushRequest};
use crate::store::models::StoredClip;
use crate::store::{queries, Store, StoreError};

/// Encrypt + push + local write-through for clips originating on this device.
///
/// One per active relay. Cheap to clone (`Arc` inside) so it can be shared by
/// the clipboard polling loop and any other producer (e.g., a manual paste
/// command).
#[derive(Clone)]
pub struct LocalPusher {
    store: Arc<Store>,
    client: Arc<RestClient>,
    enc_key: Option<[u8; 32]>,
}

#[derive(Debug, thiserror::Error)]
pub enum IngestError {
    #[error("no encryption key available — clip dropped (E2EE required)")]
    NoEncryptionKey,
    #[error("encryption failed: {0}")]
    Crypto(String),
    #[error("relay push failed: {0}")]
    Push(#[from] HttpError),
    #[error("local store write failed: {0}")]
    Store(#[from] StoreError),
}

impl LocalPusher {
    pub fn new(store: Arc<Store>, client: Arc<RestClient>, enc_key: Option<[u8; 32]>) -> Self {
        Self {
            store,
            client,
            enc_key,
        }
    }

    /// Encrypt + push a text clip, then write to the local store using the
    /// relay-assigned ID. Returns the assigned clip ID.
    pub async fn push_text(
        &self,
        raw: Vec<u8>,
        source: &str,
        label: &str,
    ) -> Result<String, IngestError> {
        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
        let original_size = raw.len() as i64;
        let ciphertext = crypto::encrypt(&key, &raw).map_err(IngestError::Crypto)?;
        let req = PushRequest {
            content: ciphertext,
            content_type: String::new(), // relay normalises empty → text/plain
            label: label.to_string(),
            source: source.to_string(),
            media_path: None,
            byte_size: original_size,
            encrypted: true,
            target_device_id: None,
        };
        let resp = self.client.push_clip_json(&req).await?;
        self.write_through(&resp.clip_id, source, "text/plain", raw, original_size)?;
        Ok(resp.clip_id)
    }

    /// Encrypt + push a PNG image, then write to the local store using the
    /// relay-assigned ID. Returns the assigned clip ID.
    pub async fn push_image_png(
        &self,
        raw_png: Vec<u8>,
        source: &str,
        label: &str,
    ) -> Result<String, IngestError> {
        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
        let original_size = raw_png.len() as i64;
        let ciphertext = crypto::encrypt(&key, &raw_png).map_err(IngestError::Crypto)?;
        let req = PushRequest {
            content: ciphertext,
            content_type: ContentType::Image.as_wire().into(),
            label: label.to_string(),
            source: source.to_string(),
            media_path: None,
            byte_size: original_size,
            encrypted: true,
            target_device_id: None,
        };
        let resp = self.client.push_clip_json(&req).await?;
        self.write_through(&resp.clip_id, source, "image/png", raw_png, original_size)?;
        Ok(resp.clip_id)
    }

    fn write_through(
        &self,
        clip_id: &str,
        source: &str,
        content_type: &str,
        raw: Vec<u8>,
        byte_size: i64,
    ) -> Result<(), IngestError> {
        let stored = StoredClip {
            id: clip_id.to_string(),
            source: source.to_string(),
            source_key: None,
            content_type: content_type.to_string(),
            content: Some(raw),
            media_path: None,
            byte_size,
            created_at: chrono::Utc::now().timestamp_millis(),
            pinned: false,
            pinned_at: None,
        };
        queries::insert_clip(&self.store, &stored)?;
        // Watermark is best-effort — failure here doesn't lose the clip.
        let _ = queries::set_watermark(&self.store, clip_id);
        Ok(())
    }
}