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//! When no encryption key is available, or when the relay push returns a
9//! transient error, the clip is queued locally via
10//! `backlog_flusher::enqueue_local` and returned as `PushOutcome::Queued`.
11//! The `flush_once` task retries those rows on the next (re)connect.
12
13use std::sync::Arc;
14
15use crate::crypto;
16use crate::http::{HttpError, RestClient};
17use crate::rest::{ContentType, PushRequest};
18use crate::store::models::StoredClip;
19use crate::store::{queries, Store, StoreError};
20
21/// Result of `LocalPusher::push_text` / `push_image_png`. The string in both
22/// variants is the clip id known to the local store. Callers that need to
23/// surface offline state to the user can match on `Queued`.
24#[derive(Debug, Clone)]
25pub enum PushOutcome {
26    /// Push to relay succeeded; carries the relay-assigned clip ID.
27    Synced(String),
28    /// Push deferred — clip persisted locally with a `local-<ULID>` id and
29    /// `synced=false`. Will be retried on the next `flush_once` trigger.
30    Queued(String),
31}
32
33/// Encrypt + push + local write-through for clips originating on this device.
34///
35/// One per active relay. Cheap to clone (`Arc` inside) so it can be shared by
36/// the clipboard polling loop and any other producer (e.g., a manual paste
37/// command).
38#[derive(Clone)]
39pub struct LocalPusher {
40    store: Arc<Store>,
41    client: Arc<RestClient>,
42    enc_key: Option<[u8; 32]>,
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum IngestError {
47    /// No encryption key configured. Currently unreachable on the push path
48    /// because `LocalPusher` queues to backlog instead — kept for downstream
49    /// callers that may still match on it.
50    #[error("no encryption key available — clip dropped (E2EE required)")]
51    NoEncryptionKey,
52    #[error("encryption failed: {0}")]
53    Crypto(String),
54    #[error("relay push failed: {0}")]
55    Push(#[from] HttpError),
56    #[error("local store write failed: {0}")]
57    Store(#[from] StoreError),
58}
59
60impl LocalPusher {
61    pub fn new(store: Arc<Store>, client: Arc<RestClient>, enc_key: Option<[u8; 32]>) -> Self {
62        Self {
63            store,
64            client,
65            enc_key,
66        }
67    }
68
69    /// Encrypt + push a text clip, then write to the local store using the
70    /// relay-assigned ID. Returns `PushOutcome::Synced(clip_id)` on success,
71    /// or `PushOutcome::Queued(local_id)` when no encryption key is available
72    /// or the relay push fails with a transient error.
73    ///
74    /// `content_type` is the canonical wire classification — one of
75    /// `ContentType::Text`, `Url`, or `Code`. Callers typically derive it
76    /// from `classify::detect(&raw)` (the raw bytes are accepted directly,
77    /// so callers do not need to UTF-8-validate up front).
78    pub async fn push_text(
79        &self,
80        raw: Vec<u8>,
81        source: &str,
82        label: &str,
83        content_type: ContentType,
84    ) -> Result<PushOutcome, IngestError> {
85        let original_size = raw.len() as i64;
86
87        if let Some(key) = self.enc_key {
88            match self
89                .try_push_text_online(&key, &raw, source, label, content_type, original_size)
90                .await
91            {
92                Ok(clip_id) => return Ok(PushOutcome::Synced(clip_id)),
93                Err(IngestError::Push(_)) => {
94                    // Transient relay error — fall through to enqueue.
95                }
96                Err(e) => return Err(e),
97            }
98        }
99
100        let clip_id = crate::sync::backlog_flusher::enqueue_local(
101            &self.store,
102            source,
103            label,
104            content_type.as_wire(),
105            raw,
106            original_size,
107        )?;
108        Ok(PushOutcome::Queued(clip_id))
109    }
110
111    /// Encrypt + push a PNG image, then write to the local store using the
112    /// relay-assigned ID. Returns `PushOutcome::Synced(clip_id)` on success,
113    /// or `PushOutcome::Queued(local_id)` when no encryption key is available
114    /// or the relay push fails with a transient error.
115    pub async fn push_image_png(
116        &self,
117        raw_png: Vec<u8>,
118        source: &str,
119        label: &str,
120    ) -> Result<PushOutcome, IngestError> {
121        let original_size = raw_png.len() as i64;
122
123        if let Some(key) = self.enc_key {
124            match self
125                .try_push_image_png_online(&key, &raw_png, source, label, original_size)
126                .await
127            {
128                Ok(clip_id) => return Ok(PushOutcome::Synced(clip_id)),
129                Err(IngestError::Push(_)) => {
130                    // Transient relay error — fall through to enqueue.
131                }
132                Err(e) => return Err(e),
133            }
134        }
135
136        let clip_id = crate::sync::backlog_flusher::enqueue_local(
137            &self.store,
138            source,
139            label,
140            ContentType::Image.as_wire(),
141            raw_png,
142            original_size,
143        )?;
144        Ok(PushOutcome::Queued(clip_id))
145    }
146
147    async fn try_push_text_online(
148        &self,
149        key: &[u8; 32],
150        raw: &[u8],
151        source: &str,
152        label: &str,
153        content_type: ContentType,
154        original_size: i64,
155    ) -> Result<String, IngestError> {
156        let ciphertext = crypto::encrypt(key, raw).map_err(IngestError::Crypto)?;
157        let wire = content_type.as_wire();
158        let req = PushRequest {
159            content: ciphertext,
160            content_type: wire.to_string(),
161            label: label.to_string(),
162            source: source.to_string(),
163            media_path: None,
164            byte_size: original_size,
165            encrypted: true,
166            target_device_id: None,
167            client_created_at: None,
168            idempotency_key: None,
169        };
170        let resp = self.client.push_clip_json(&req).await?;
171        self.write_through(&resp.clip_id, source, wire, raw.to_vec(), original_size)?;
172        Ok(resp.clip_id)
173    }
174
175    async fn try_push_image_png_online(
176        &self,
177        key: &[u8; 32],
178        raw_png: &[u8],
179        source: &str,
180        label: &str,
181        original_size: i64,
182    ) -> Result<String, IngestError> {
183        let ciphertext = crypto::encrypt(key, raw_png).map_err(IngestError::Crypto)?;
184        let req = PushRequest {
185            content: ciphertext,
186            content_type: ContentType::Image.as_wire().into(),
187            label: label.to_string(),
188            source: source.to_string(),
189            media_path: None,
190            byte_size: original_size,
191            encrypted: true,
192            target_device_id: None,
193            client_created_at: None,
194            idempotency_key: None,
195        };
196        let resp = self.client.push_clip_json(&req).await?;
197        self.write_through(
198            &resp.clip_id,
199            source,
200            ContentType::Image.as_wire(),
201            raw_png.to_vec(),
202            original_size,
203        )?;
204        Ok(resp.clip_id)
205    }
206
207    fn write_through(
208        &self,
209        clip_id: &str,
210        source: &str,
211        content_type: &str,
212        raw: Vec<u8>,
213        byte_size: i64,
214    ) -> Result<(), IngestError> {
215        let stored = StoredClip {
216            id: clip_id.to_string(),
217            source: source.to_string(),
218            source_key: None,
219            content_type: content_type.to_string(),
220            content: Some(raw),
221            media_path: None,
222            byte_size,
223            created_at: chrono::Utc::now().timestamp_millis(),
224            pinned: false,
225            pinned_at: None,
226            synced: true,
227        };
228        queries::insert_clip(&self.store, &stored)?;
229        // Watermark is best-effort — failure here doesn't lose the clip.
230        let _ = queries::set_watermark(&self.store, clip_id);
231        Ok(())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::store::queries;
239    use std::sync::Arc;
240
241    fn fresh_store() -> Arc<Store> {
242        Arc::new(Store::open(std::path::Path::new(":memory:")).unwrap())
243    }
244
245    fn offline_client() -> Arc<RestClient> {
246        Arc::new(RestClient::for_test_offline())
247    }
248
249    #[tokio::test]
250    async fn push_text_queues_when_no_key() {
251        let store = fresh_store();
252        let pusher = LocalPusher::new(store.clone(), offline_client(), None);
253        let outcome = pusher
254            .push_text(
255                b"hello".to_vec(),
256                "remote:host",
257                "",
258                crate::rest::ContentType::Text,
259            )
260            .await
261            .expect("push_text");
262        match outcome {
263            PushOutcome::Queued(id) => assert!(id.starts_with("local-")),
264            PushOutcome::Synced(_) => panic!("expected Queued, got Synced"),
265        }
266        let rows = queries::list_unsynced_clips(&store).unwrap();
267        assert_eq!(rows.len(), 1);
268        assert_eq!(rows[0].content.as_deref(), Some(&b"hello"[..]));
269    }
270
271    #[tokio::test]
272    async fn push_text_queues_when_network_fails() {
273        let store = fresh_store();
274        let pusher = LocalPusher::new(store.clone(), offline_client(), Some([9u8; 32]));
275        let outcome = pusher
276            .push_text(
277                b"hello".to_vec(),
278                "remote:host",
279                "",
280                crate::rest::ContentType::Text,
281            )
282            .await
283            .expect("push_text");
284        assert!(matches!(outcome, PushOutcome::Queued(_)));
285    }
286
287    #[tokio::test]
288    async fn push_image_png_queues_when_no_key() {
289        let store = fresh_store();
290        let pusher = LocalPusher::new(store.clone(), offline_client(), None);
291        let outcome = pusher
292            .push_image_png(b"\x89PNG\r\n\x1a\n".to_vec(), "remote:host", "")
293            .await
294            .expect("push_image_png");
295        match outcome {
296            PushOutcome::Queued(id) => assert!(id.starts_with("local-")),
297            PushOutcome::Synced(_) => panic!("expected Queued, got Synced"),
298        }
299        let rows = queries::list_unsynced_clips(&store).unwrap();
300        assert_eq!(rows.len(), 1);
301        assert_eq!(rows[0].content_type, "image");
302    }
303
304    #[tokio::test]
305    async fn push_image_png_queues_when_network_fails() {
306        let store = fresh_store();
307        let pusher = LocalPusher::new(store.clone(), offline_client(), Some([9u8; 32]));
308        let outcome = pusher
309            .push_image_png(b"\x89PNG\r\n\x1a\n".to_vec(), "remote:host", "")
310            .await
311            .expect("push_image_png");
312        assert!(matches!(outcome, PushOutcome::Queued(_)));
313    }
314}