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