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(&text)`.
65    pub async fn push_text(
66        &self,
67        raw: Vec<u8>,
68        source: &str,
69        label: &str,
70        content_type: ContentType,
71    ) -> Result<String, IngestError> {
72        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
73        let original_size = raw.len() as i64;
74        let ciphertext = crypto::encrypt(&key, &raw).map_err(IngestError::Crypto)?;
75        let wire = content_type.as_wire();
76        let req = PushRequest {
77            content: ciphertext,
78            content_type: wire.to_string(),
79            label: label.to_string(),
80            source: source.to_string(),
81            media_path: None,
82            byte_size: original_size,
83            encrypted: true,
84            target_device_id: None,
85        };
86        let resp = self.client.push_clip_json(&req).await?;
87        self.write_through(&resp.clip_id, source, wire, raw, original_size)?;
88        Ok(resp.clip_id)
89    }
90
91    /// Encrypt + push a PNG image, then write to the local store using the
92    /// relay-assigned ID. Returns the assigned clip ID.
93    pub async fn push_image_png(
94        &self,
95        raw_png: Vec<u8>,
96        source: &str,
97        label: &str,
98    ) -> Result<String, IngestError> {
99        let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
100        let original_size = raw_png.len() as i64;
101        let ciphertext = crypto::encrypt(&key, &raw_png).map_err(IngestError::Crypto)?;
102        let req = PushRequest {
103            content: ciphertext,
104            content_type: ContentType::Image.as_wire().into(),
105            label: label.to_string(),
106            source: source.to_string(),
107            media_path: None,
108            byte_size: original_size,
109            encrypted: true,
110            target_device_id: None,
111        };
112        let resp = self.client.push_clip_json(&req).await?;
113        self.write_through(
114            &resp.clip_id,
115            source,
116            ContentType::Image.as_wire(),
117            raw_png,
118            original_size,
119        )?;
120        Ok(resp.clip_id)
121    }
122
123    fn write_through(
124        &self,
125        clip_id: &str,
126        source: &str,
127        content_type: &str,
128        raw: Vec<u8>,
129        byte_size: i64,
130    ) -> Result<(), IngestError> {
131        let stored = StoredClip {
132            id: clip_id.to_string(),
133            source: source.to_string(),
134            source_key: None,
135            content_type: content_type.to_string(),
136            content: Some(raw),
137            media_path: None,
138            byte_size,
139            created_at: chrono::Utc::now().timestamp_millis(),
140            pinned: false,
141            pinned_at: None,
142        };
143        queries::insert_clip(&self.store, &stored)?;
144        // Watermark is best-effort — failure here doesn't lose the clip.
145        let _ = queries::set_watermark(&self.store, clip_id);
146        Ok(())
147    }
148}