Skip to main content

client_core/sync/
local_pusher.rs

1//! Local-clip ingest path.
2//!
3//! Captures clips detected on the local clipboard, encrypts them, pushes to
4//! the relay, then write-throughs to the shared store using the relay-assigned
5//! clip ID. Mirrors the `cinch push` flow so the desktop and CLI converge on a
6//! single push pipeline.
7//!
8//! The relay is the source of truth for clip IDs; we always wait for the push
9//! response before writing locally so the local row carries the same ULID the
10//! WS broadcast will emit. This avoids a duplicate row when the broadcast loops
11//! back to this device.
12//!
13//! E2EE is mandatory: callers must supply an encryption key. With no key the
14//! push is rejected (`IngestError::NoEncryptionKey`); we never store plaintext
15//! in the unencrypted-clip path because the receiver-side store would reject
16//! it on the next backfill anyway.
17
18use std::sync::Arc;
19
20use crate::crypto;
21use crate::http::{HttpError, RestClient};
22use crate::rest::{ContentType, PushRequest};
23use crate::store::models::StoredClip;
24use crate::store::{queries, Store, StoreError};
25
26/// Encrypt + push + local write-through for clips originating on this device.
27///
28/// One per active relay. Cheap to clone (`Arc` inside) so it can be shared by
29/// the clipboard polling loop and any other producer (e.g., a manual paste
30/// command).
31#[derive(Clone)]
32pub struct LocalPusher {
33    store: Arc<Store>,
34    client: Arc<RestClient>,
35    enc_key: Option<[u8; 32]>,
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum IngestError {
40    #[error("no encryption key available — clip dropped (E2EE required)")]
41    NoEncryptionKey,
42    #[error("encryption failed: {0}")]
43    Crypto(String),
44    #[error("relay push failed: {0}")]
45    Push(#[from] HttpError),
46    #[error("local store write failed: {0}")]
47    Store(#[from] StoreError),
48}
49
50impl LocalPusher {
51    pub fn new(store: Arc<Store>, client: Arc<RestClient>, enc_key: Option<[u8; 32]>) -> Self {
52        Self {
53            store,
54            client,
55            enc_key,
56        }
57    }
58
59    /// Encrypt + push a text clip, then write to the local store using the
60    /// relay-assigned ID. Returns the assigned clip ID.
61    ///
62    /// `content_type` is the canonical wire classification — one of
63    /// `ContentType::Text`, `Url`, or `Code`. Callers typically derive it
64    /// from `classify::detect(&raw)` (the raw bytes are accepted directly,
65    /// so callers do not need to UTF-8-validate up front).
66    pub async fn push_text(
67        &self,
68        raw: Vec<u8>,
69        source: &str,
70        label: &str,
71        content_type: ContentType,
72    ) -> Result<String, IngestError> {
73        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
74        let original_size = raw.len() as i64;
75        let ciphertext = crypto::encrypt(&key, &raw).map_err(IngestError::Crypto)?;
76        let wire = content_type.as_wire();
77        let req = PushRequest {
78            content: ciphertext,
79            content_type: wire.to_string(),
80            label: label.to_string(),
81            source: source.to_string(),
82            media_path: None,
83            byte_size: original_size,
84            encrypted: true,
85            target_device_id: None,
86        };
87        let resp = self.client.push_clip_json(&req).await?;
88        self.write_through(&resp.clip_id, source, wire, raw, original_size)?;
89        Ok(resp.clip_id)
90    }
91
92    /// Encrypt + push a PNG image, then write to the local store using the
93    /// relay-assigned ID. Returns the assigned clip ID.
94    pub async fn push_image_png(
95        &self,
96        raw_png: Vec<u8>,
97        source: &str,
98        label: &str,
99    ) -> Result<String, IngestError> {
100        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
101        let original_size = raw_png.len() as i64;
102        let ciphertext = crypto::encrypt(&key, &raw_png).map_err(IngestError::Crypto)?;
103        let req = PushRequest {
104            content: ciphertext,
105            content_type: ContentType::Image.as_wire().into(),
106            label: label.to_string(),
107            source: source.to_string(),
108            media_path: None,
109            byte_size: original_size,
110            encrypted: true,
111            target_device_id: None,
112        };
113        let resp = self.client.push_clip_json(&req).await?;
114        self.write_through(
115            &resp.clip_id,
116            source,
117            ContentType::Image.as_wire(),
118            raw_png,
119            original_size,
120        )?;
121        Ok(resp.clip_id)
122    }
123
124    fn write_through(
125        &self,
126        clip_id: &str,
127        source: &str,
128        content_type: &str,
129        raw: Vec<u8>,
130        byte_size: i64,
131    ) -> Result<(), IngestError> {
132        let stored = StoredClip {
133            id: clip_id.to_string(),
134            source: source.to_string(),
135            source_key: None,
136            content_type: content_type.to_string(),
137            content: Some(raw),
138            media_path: None,
139            byte_size,
140            created_at: chrono::Utc::now().timestamp_millis(),
141            pinned: false,
142            pinned_at: None,
143        };
144        queries::insert_clip(&self.store, &stored)?;
145        // Watermark is best-effort — failure here doesn't lose the clip.
146        let _ = queries::set_watermark(&self.store, clip_id);
147        Ok(())
148    }
149}