Skip to main content

client_core/
http.rs

1//! REST client for the relay's legacy HTTP+JSON endpoints.
2//!
3//! Targets the same routes the Go CLI uses today: `POST /clips`,
4//! `POST /clips/binary`, `GET /clips/latest`, `GET /devices`,
5//! `POST /auth/device-code`, `GET /auth/device-code/poll`,
6//! `POST /auth/device/revoke`, `POST /auth/key-bundle/retry`.
7//! `GET /clips/latest` supports no params (absolute latest), `?source=...`,
8//! and `?exclude_source=...` for the three filter modes.
9//! The legacy `/auth/pair` and `/auth/pair-token/new` routes were
10//! retired in the OAuth-only migration.
11//!
12//! Retry: 3 attempts with exponential backoff (1s, 2s) matching
13//! `cinch/cmd/push.go:188-203`.
14
15use std::time::Duration;
16
17use reqwest::{header::HeaderMap, multipart, Client, StatusCode};
18
19use crate::protocol::{Clip, DeviceInfo};
20use crate::rest::{
21    DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
22    DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
23    PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
24};
25use crate::version::ClientInfo;
26
27const MAX_ATTEMPTS: u32 = 3;
28const REQUEST_TIMEOUT_SECS: u64 = 30;
29
30/// Filter shape for `RestClient::list_clips`. Mirrors the relay's `ListFilter`.
31#[derive(Debug, Default, Clone)]
32pub struct ListClipsFilter {
33    pub limit: u32,
34    pub source: Option<String>,
35    pub exclude_source: Option<String>,
36    pub exclude_image: bool,
37    pub exclude_text: bool,
38    pub clip_ids: Vec<String>,
39}
40
41#[derive(Debug, thiserror::Error)]
42pub enum HttpError {
43    #[error("network: {0}")]
44    Network(String),
45    #[error("auth required (401)")]
46    Unauthorized,
47    #[error("relay error ({status}): {message}")]
48    Relay {
49        status: u16,
50        message: String,
51        fix: String,
52    },
53    #[error("decode response: {0}")]
54    Decode(String),
55    #[error("build request: {0}")]
56    Build(String),
57}
58
59#[cfg(test)]
60enum TestMode {
61    Offline,
62    Recording {
63        pushes: std::sync::Arc<std::sync::Mutex<Vec<PushRequest>>>,
64        next_seq: std::sync::Arc<std::sync::atomic::AtomicUsize>,
65    },
66    Scheduled {
67        pushes: std::sync::Arc<std::sync::Mutex<Vec<PushRequest>>>,
68        schedule: std::sync::Arc<std::sync::Mutex<Vec<FakePush>>>,
69        next_seq: std::sync::Arc<std::sync::atomic::AtomicUsize>,
70    },
71}
72
73#[cfg(test)]
74impl std::fmt::Debug for TestMode {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            TestMode::Offline => write!(f, "TestMode::Offline"),
78            TestMode::Recording { .. } => write!(f, "TestMode::Recording"),
79            TestMode::Scheduled { .. } => write!(f, "TestMode::Scheduled"),
80        }
81    }
82}
83
84#[cfg(test)]
85impl Clone for TestMode {
86    fn clone(&self) -> Self {
87        match self {
88            TestMode::Offline => TestMode::Offline,
89            TestMode::Recording { pushes, next_seq } => TestMode::Recording {
90                pushes: std::sync::Arc::clone(pushes),
91                next_seq: std::sync::Arc::clone(next_seq),
92            },
93            TestMode::Scheduled {
94                pushes,
95                schedule,
96                next_seq,
97            } => TestMode::Scheduled {
98                pushes: std::sync::Arc::clone(pushes),
99                schedule: std::sync::Arc::clone(schedule),
100                next_seq: std::sync::Arc::clone(next_seq),
101            },
102        }
103    }
104}
105
106/// Outcome descriptor for a single simulated push in test clients.
107#[cfg(test)]
108#[derive(Clone)]
109pub enum FakePush {
110    Ok,
111    Network,
112    Relay { status: u16, msg: String },
113}
114
115#[derive(Debug, Clone)]
116pub struct RestClient {
117    base_url: String,
118    token: String,
119    client: Client,
120    client_info: ClientInfo,
121    #[cfg(test)]
122    test_mode: Option<TestMode>,
123}
124
125impl RestClient {
126    /// Construct a new client. `relay_url` is trimmed of any trailing slash.
127    /// `client_info` is attached to every request as `X-Cinch-Client-Version`
128    /// and `X-Cinch-Client-Type` default headers, so the relay's HTTP
129    /// middleware can persist the caller's version automatically without each
130    /// call site re-setting the headers.
131    pub fn new(
132        relay_url: impl Into<String>,
133        token: impl Into<String>,
134        client_info: ClientInfo,
135    ) -> Result<Self, HttpError> {
136        let base = relay_url.into().trim_end_matches('/').to_string();
137        let mut headers = HeaderMap::new();
138        for (name, value) in client_info.http_headers() {
139            headers.insert(name, value);
140        }
141        let client = Client::builder()
142            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
143            .default_headers(headers)
144            .build()
145            .map_err(|e| HttpError::Build(e.to_string()))?;
146        Ok(Self {
147            base_url: base,
148            token: token.into(),
149            client,
150            client_info,
151            #[cfg(test)]
152            test_mode: None,
153        })
154    }
155
156    /// Borrow the `ClientInfo` this client was constructed with. Useful for
157    /// callers that also drive a WS connection and want to attach the same
158    /// version metadata to the `client_hello` payload.
159    pub fn client_info(&self) -> &ClientInfo {
160        &self.client_info
161    }
162
163    /// Test-only constructor that wires the client to a guaranteed-unreachable
164    /// localhost port. Every `push_clip_json` call returns
165    /// `Err(HttpError::Network(_))` within milliseconds.
166    #[cfg(test)]
167    pub fn for_test_offline() -> Self {
168        let mut c = Self::new(
169            "http://127.0.0.1:1",
170            "test-token",
171            crate::version::ClientInfo::for_test(),
172        )
173        .expect("offline test client construction must not fail");
174        c.test_mode = Some(TestMode::Offline);
175        c
176    }
177
178    /// Test-only constructor that records every `push_clip_json` call and
179    /// returns a synthetic relay ID for each. Does not touch the network.
180    #[cfg(test)]
181    pub fn for_test_recording() -> Self {
182        Self::for_test_offline().with_mode(TestMode::Recording {
183            pushes: Default::default(),
184            next_seq: Default::default(),
185        })
186    }
187
188    /// Test-only constructor with a pre-loaded failure schedule. Each entry in
189    /// `schedule` determines the outcome of the corresponding push call in
190    /// order; if the schedule is exhausted, subsequent calls return a network
191    /// error.
192    #[cfg(test)]
193    pub fn for_test_with_failures(schedule: Vec<FakePush>) -> Self {
194        Self::for_test_offline().with_mode(TestMode::Scheduled {
195            pushes: Default::default(),
196            schedule: std::sync::Arc::new(std::sync::Mutex::new(schedule)),
197            next_seq: Default::default(),
198        })
199    }
200
201    /// Return the list of push requests recorded by this client (works for
202    /// both `Recording` and `Scheduled` modes).
203    #[cfg(test)]
204    pub fn recorded_pushes(&self) -> Vec<PushRequest> {
205        match &self.test_mode {
206            Some(TestMode::Recording { pushes, .. }) | Some(TestMode::Scheduled { pushes, .. }) => {
207                pushes.lock().unwrap().clone()
208            }
209            _ => Vec::new(),
210        }
211    }
212
213    #[cfg(test)]
214    fn with_mode(mut self, mode: TestMode) -> Self {
215        self.test_mode = Some(mode);
216        self
217    }
218
219    #[cfg(test)]
220    async fn handle_test_push(
221        &self,
222        mode: &TestMode,
223        req: &PushRequest,
224    ) -> Result<PushResponse, HttpError> {
225        match mode {
226            TestMode::Offline => Err(HttpError::Network("offline test client".into())),
227            TestMode::Recording { pushes, next_seq } => {
228                pushes.lock().unwrap().push(req.clone());
229                let n = next_seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
230                Ok(PushResponse {
231                    clip_id: format!("01HRELAY{:020}", n),
232                    byte_size: 0,
233                })
234            }
235            TestMode::Scheduled {
236                pushes,
237                schedule,
238                next_seq,
239            } => {
240                pushes.lock().unwrap().push(req.clone());
241                let fake = {
242                    let mut s = schedule.lock().unwrap();
243                    if s.is_empty() {
244                        return Err(HttpError::Network("schedule exhausted".into()));
245                    }
246                    s.remove(0)
247                };
248                match fake {
249                    FakePush::Ok => {
250                        let n = next_seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
251                        Ok(PushResponse {
252                            clip_id: format!("01HRELAY{:020}", n),
253                            byte_size: 0,
254                        })
255                    }
256                    FakePush::Network => Err(HttpError::Network("fake network".into())),
257                    FakePush::Relay { status, msg } => Err(HttpError::Relay {
258                        status,
259                        message: msg,
260                        fix: String::new(),
261                    }),
262                }
263            }
264        }
265    }
266
267    /// `POST /clips` with JSON body — text and encrypted-binary path.
268    pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
269        #[cfg(test)]
270        {
271            if let Some(mode) = &self.test_mode {
272                return self.handle_test_push(mode, req).await;
273            }
274        }
275        let url = format!("{}/clips", self.base_url);
276        let resp = self
277            .send_with_retry(|| {
278                self.client
279                    .post(&url)
280                    .bearer_auth(&self.token)
281                    .json(req)
282                    .build()
283            })
284            .await?;
285        decode_push_response(resp).await
286    }
287
288    /// `POST /clips/binary` — multipart form for unencrypted binary.
289    /// `data` is the raw file bytes; metadata fields are sent as form fields.
290    pub async fn push_clip_binary(
291        &self,
292        data: Vec<u8>,
293        content_type: &str,
294        source: &str,
295        label: Option<&str>,
296        target_device_id: Option<&str>,
297    ) -> Result<PushResponse, HttpError> {
298        let url = format!("{}/clips/binary", self.base_url);
299        let mut last_err: Option<HttpError> = None;
300        for attempt in 0..MAX_ATTEMPTS {
301            if attempt > 0 {
302                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
303            }
304            // Multipart parts must be rebuilt per attempt because their bodies
305            // are consumed by `.send()`.
306            let mut form = multipart::Form::new()
307                .part(
308                    "file",
309                    multipart::Part::bytes(data.clone()).file_name("upload"),
310                )
311                .text("content_type", content_type.to_string())
312                .text("source", source.to_string());
313            if let Some(l) = label.filter(|s| !s.is_empty()) {
314                form = form.text("label", l.to_string());
315            }
316            if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
317                form = form.text("target_device_id", d.to_string());
318            }
319            let resp = self
320                .client
321                .post(&url)
322                .bearer_auth(&self.token)
323                .multipart(form)
324                .send()
325                .await;
326            match resp {
327                Ok(r) => return decode_push_response(r).await,
328                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
329            }
330        }
331        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
332    }
333
334    /// `GET /clips/latest?source=...` — most recent clip matching `source`.
335    pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
336        let url = format!("{}/clips/latest", self.base_url);
337        let resp = self
338            .send_with_retry(|| {
339                self.client
340                    .get(&url)
341                    .bearer_auth(&self.token)
342                    .query(&[("source", source)])
343                    .build()
344            })
345            .await?;
346        decode_json_response::<Clip>(resp).await
347    }
348
349    /// `GET /clips/latest` (no params) — most recent clip across all devices.
350    pub async fn get_latest_clip_any(&self) -> Result<Clip, HttpError> {
351        let url = format!("{}/clips/latest", self.base_url);
352        let resp = self
353            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
354            .await?;
355        decode_json_response::<Clip>(resp).await
356    }
357
358    /// `GET /clips/{id}/media` — raw image bytes for image clips.
359    pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
360        let url = format!("{}/clips/{}/media", self.base_url, clip_id);
361        let resp = self
362            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
363            .await?;
364        let status = resp.status();
365        if status == StatusCode::UNAUTHORIZED {
366            return Err(HttpError::Unauthorized);
367        }
368        if !status.is_success() {
369            return Err(HttpError::Relay {
370                status: status.as_u16(),
371                message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
372                fix: String::new(),
373            });
374        }
375        resp.bytes()
376            .await
377            .map(|b| b.to_vec())
378            .map_err(|e| HttpError::Decode(e.to_string()))
379    }
380
381    /// `POST /auth/device-code` — start the device-code flow. The relay
382    /// returns a `verification_uri` for the user to open in a browser.
383    /// `machine_id` is opaque (empty string disables relay-side dedup).
384    pub async fn start_device_code(
385        &self,
386        relay_url: &str,
387        hostname: &str,
388        machine_id: &str,
389        user_hint: Option<&str>,
390    ) -> Result<DeviceCodeResponse, HttpError> {
391        let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
392        let req = DeviceCodeRequest {
393            hostname: Some(hostname.to_string()),
394            machine_id: if machine_id.is_empty() {
395                None
396            } else {
397                Some(machine_id.to_string())
398            },
399            user_hint: user_hint.map(|s| s.to_string()),
400        };
401        let resp = self
402            .client
403            .post(&url)
404            .json(&req)
405            .send()
406            .await
407            .map_err(|e| HttpError::Network(e.to_string()))?;
408        decode_json_response::<DeviceCodeResponse>(resp).await
409    }
410
411    /// `GET /auth/device-code/poll?code=...` — single poll. Caller drives
412    /// the loop and respects `interval` from the start response.
413    pub async fn poll_device_code(
414        &self,
415        relay_url: &str,
416        device_code: &str,
417    ) -> Result<DeviceCodePollResponse, HttpError> {
418        let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
419        let resp = self
420            .client
421            .get(&url)
422            .query(&[("code", device_code)])
423            .send()
424            .await
425            .map_err(|e| HttpError::Network(e.to_string()))?;
426        decode_json_response::<DeviceCodePollResponse>(resp).await
427    }
428
429    /// `POST /auth/device-code/complete` — approve a pending device-code
430    /// login from an already-authenticated local device.
431    pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
432        let url = format!("{}/auth/device-code/complete", self.base_url);
433        let body = DeviceCodeCompleteRequest {
434            user_code: user_code.to_string(),
435            user_id: String::new(),
436            device_id: String::new(),
437            token: String::new(),
438        };
439        let resp = self
440            .client
441            .post(&url)
442            .bearer_auth(&self.token)
443            .json(&body)
444            .send()
445            .await
446            .map_err(|e| HttpError::Network(e.to_string()))?;
447        decode_json_response::<serde_json::Value>(resp)
448            .await
449            .map(|_| ())
450    }
451
452    /// `POST /cinch.v1.AuthService/DeviceCodeDeny` (Connect-RPC unary, JSON encoding)
453    /// — reject a pending device-code login from this already-signed-in device.
454    pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
455        let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
456        let body = DeviceCodeDenyRequest {
457            user_code: user_code.to_string(),
458        };
459        let resp = self
460            .client
461            .post(&url)
462            .bearer_auth(&self.token)
463            .json(&body)
464            .send()
465            .await
466            .map_err(|e| HttpError::Network(e.to_string()))?;
467        decode_json_response::<serde_json::Value>(resp)
468            .await
469            .map(|_| ())
470    }
471
472    /// `GET /health` — liveness probe used by the wizard before issuing a
473    /// device code, so URL typos surface as a clean error before the user
474    /// is sent to a browser.
475    pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
476        let url = format!("{}/health", relay_url.trim_end_matches('/'));
477        let resp = self
478            .client
479            .get(&url)
480            .send()
481            .await
482            .map_err(|e| HttpError::Network(e.to_string()))?;
483        if resp.status().is_success() {
484            Ok(())
485        } else {
486            Err(HttpError::Relay {
487                status: resp.status().as_u16(),
488                message: format!("health check failed: HTTP {}", resp.status().as_u16()),
489                fix: String::new(),
490            })
491        }
492    }
493
494    /// `POST /auth/key-bundle` — publish an encrypted user-key bundle
495    /// for `target_device_id`. Called by any device that holds the
496    /// user's master key when the relay broadcasts a
497    /// `key_exchange_requested` event for a freshly-paired peer.
498    /// `ephemeral_public_key` and `encrypted_bundle` are both
499    /// base64url-encoded. Bearer-authenticated.
500    pub async fn post_key_bundle(
501        &self,
502        target_device_id: &str,
503        ephemeral_public_key: &str,
504        encrypted_bundle: &str,
505    ) -> Result<(), HttpError> {
506        let url = format!("{}/auth/key-bundle", self.base_url);
507        let body = KeyBundlePutRequest {
508            device_id: target_device_id.to_string(),
509            ephemeral_public_key: ephemeral_public_key.to_string(),
510            encrypted_bundle: encrypted_bundle.to_string(),
511        };
512        let resp = self
513            .client
514            .post(&url)
515            .bearer_auth(&self.token)
516            .json(&body)
517            .send()
518            .await
519            .map_err(|e| HttpError::Network(e.to_string()))?;
520        let status = resp.status();
521        if status == StatusCode::UNAUTHORIZED {
522            return Err(HttpError::Unauthorized);
523        }
524        if !status.is_success() {
525            return Err(HttpError::Relay {
526                status: status.as_u16(),
527                message: format!("post key bundle failed: HTTP {}", status.as_u16()),
528                fix: String::new(),
529            });
530        }
531        Ok(())
532    }
533
534    /// `POST /auth/device/public-key` — register the X25519 public key
535    /// for the calling device so the relay can include it in
536    /// ListPendingKeyExchanges sweeps and broadcast
537    /// `key_exchange_requested` events for it. Called once after the
538    /// OAuth-only login flow finishes installing local credentials.
539    /// Bearer-authenticated.
540    pub async fn register_device_public_key(
541        &self,
542        public_key: &str,
543        fingerprint: &str,
544    ) -> Result<(), HttpError> {
545        let url = format!("{}/auth/device/public-key", self.base_url);
546        let body = RegisterDevicePublicKeyRequest {
547            public_key: public_key.to_string(),
548            fingerprint: fingerprint.to_string(),
549        };
550        let resp = self
551            .client
552            .post(&url)
553            .bearer_auth(&self.token)
554            .json(&body)
555            .send()
556            .await
557            .map_err(|e| HttpError::Network(e.to_string()))?;
558        let status = resp.status();
559        if status == StatusCode::UNAUTHORIZED {
560            return Err(HttpError::Unauthorized);
561        }
562        if !status.is_success() {
563            return Err(HttpError::Relay {
564                status: status.as_u16(),
565                message: format!("register public key failed: HTTP {}", status.as_u16()),
566                fix: String::new(),
567            });
568        }
569        Ok(())
570    }
571
572    /// `POST /auth/key-bundle/retry` — ask the relay to re-broadcast
573    /// `key_exchange_requested` for the calling device. Used when the
574    /// initial key handoff missed (no key-bearer was online at login
575    /// time). Bearer-authenticated.
576    pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
577        let url = format!("{}/auth/key-bundle/retry", self.base_url);
578        let resp = self
579            .client
580            .post(&url)
581            .bearer_auth(&self.token)
582            .send()
583            .await
584            .map_err(|e| HttpError::Network(e.to_string()))?;
585        let status = resp.status();
586        if status == StatusCode::UNAUTHORIZED {
587            return Err(HttpError::Unauthorized);
588        }
589        if !status.is_success() {
590            return Err(HttpError::Relay {
591                status: status.as_u16(),
592                message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
593                fix: String::new(),
594            });
595        }
596        Ok(())
597    }
598
599    /// `POST /auth/display-name` — update `users.display_name` for the
600    /// calling user. Returns the stored value after server-side trim.
601    /// Empty input is rejected client-side without a network round trip.
602    /// Bearer-authenticated.
603    pub async fn set_display_name(&self, name: &str) -> Result<String, HttpError> {
604        if name.trim().is_empty() {
605            return Err(HttpError::Relay {
606                status: 400,
607                message: "display_name must not be empty".into(),
608                fix: "Pass a non-empty name.".into(),
609            });
610        }
611        #[derive(serde::Serialize)]
612        struct Req<'a> {
613            display_name: &'a str,
614        }
615        #[derive(serde::Deserialize)]
616        struct Resp {
617            #[serde(default)]
618            display_name: String,
619        }
620        let url = format!("{}/auth/display-name", self.base_url);
621        let resp = self
622            .client
623            .post(&url)
624            .bearer_auth(&self.token)
625            .json(&Req { display_name: name })
626            .send()
627            .await
628            .map_err(|e| HttpError::Network(e.to_string()))?;
629        let status = resp.status();
630        if status == StatusCode::UNAUTHORIZED {
631            return Err(HttpError::Unauthorized);
632        }
633        if !status.is_success() {
634            return Err(HttpError::Relay {
635                status: status.as_u16(),
636                message: format!("set_display_name failed: HTTP {}", status.as_u16()),
637                fix: String::new(),
638            });
639        }
640        let body: Resp = resp
641            .json()
642            .await
643            .map_err(|e| HttpError::Network(format!("decode set_display_name response: {}", e)))?;
644        Ok(body.display_name)
645    }
646
647    /// `POST /auth/device/revoke` — revoke the active device server-side.
648    /// Best-effort: callers should still wipe local credentials regardless
649    /// of relay reachability.
650    pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
651        let url = format!("{}/auth/device/revoke", self.base_url);
652        let body = DeviceRevokeRequest {
653            device_id: device_id.to_string(),
654        };
655        let resp = self
656            .client
657            .post(&url)
658            .bearer_auth(&self.token)
659            .json(&body)
660            .send()
661            .await
662            .map_err(|e| HttpError::Network(e.to_string()))?;
663        let status = resp.status();
664        if !status.is_success() {
665            return Err(HttpError::Relay {
666                status: status.as_u16(),
667                message: format!("revoke failed: HTTP {}", status.as_u16()),
668                fix: String::new(),
669            });
670        }
671        Ok(())
672    }
673
674    /// `PUT /devices/{device_id}/nickname` — set or clear a human-readable
675    /// nickname for a paired device. An empty string clears the nickname.
676    /// Task 5.9 uses this path; the desktop `set_device_nickname` command
677    /// delegates here rather than calling reqwest directly.
678    pub async fn set_device_nickname(
679        &self,
680        device_id: &str,
681        nickname: &str,
682    ) -> Result<(), HttpError> {
683        let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
684        #[derive(serde::Serialize)]
685        struct NicknameBody<'a> {
686            nickname: &'a str,
687        }
688        let resp = self
689            .client
690            .put(&url)
691            .bearer_auth(&self.token)
692            .json(&NicknameBody { nickname })
693            .send()
694            .await
695            .map_err(|e| HttpError::Network(e.to_string()))?;
696        let status = resp.status();
697        if !status.is_success() {
698            let body = resp.text().await.unwrap_or_default();
699            return Err(HttpError::Relay {
700                status: status.as_u16(),
701                message: format!("set_device_nickname failed: {}", body),
702                fix: String::new(),
703            });
704        }
705        Ok(())
706    }
707
708    /// `PUT /devices/self/retention` — set this device's remote retention
709    /// (in days). The relay only exposes a self-targeted endpoint; per-device
710    /// retention writes are not supported over REST.
711    pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
712        let url = format!("{}/devices/self/retention", self.base_url);
713        #[derive(serde::Serialize)]
714        struct Body {
715            remote_retention_days: i32,
716        }
717        let resp = self
718            .client
719            .put(&url)
720            .bearer_auth(&self.token)
721            .json(&Body {
722                remote_retention_days: days,
723            })
724            .send()
725            .await
726            .map_err(|e| HttpError::Network(e.to_string()))?;
727        let status = resp.status();
728        if !status.is_success() {
729            let body = resp.text().await.unwrap_or_default();
730            return Err(HttpError::Relay {
731                status: status.as_u16(),
732                message: format!("set_remote_retention failed: {}", body),
733                fix: String::new(),
734            });
735        }
736        Ok(())
737    }
738
739    /// `GET /auth/key-bundle` — fetch the encrypted user-key bundle the
740    /// desktop publishes after a pair. Bearer-authenticated.
741    /// Always returns 200; an absent bundle is signalled by empty
742    /// `ephemeral_public_key`/`encrypted_bundle` plus a non-empty
743    /// `pending_since` RFC3339 timestamp, so callers can poll without
744    /// distinguishing "not yet" from "device unknown" via status code.
745    pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
746        let url = format!("{}/auth/key-bundle", self.base_url);
747        let resp = self
748            .client
749            .get(&url)
750            .bearer_auth(&self.token)
751            .send()
752            .await
753            .map_err(|e| HttpError::Network(e.to_string()))?;
754        decode_json_response::<KeyBundleResponse>(resp).await
755    }
756
757    /// `GET /clips[?since=<rfc3339>][&limit=<n>]` — list clips, optionally filtered to those
758    /// newer than `since`. Returns oldest-first when `since` is provided.
759    /// `limit` caps the number of results (relay maximum is 100).
760    pub async fn list_clips_since(
761        &self,
762        since: Option<chrono::DateTime<chrono::Utc>>,
763        limit: u32,
764    ) -> Result<Vec<Clip>, HttpError> {
765        let url = format!("{}/clips", self.base_url);
766        let resp = self
767            .send_with_retry(|| {
768                let mut req = self.client.get(&url).bearer_auth(&self.token);
769                if let Some(ts) = since {
770                    req = req.query(&[("since", ts.to_rfc3339())]);
771                }
772                req = req.query(&[("limit", limit.to_string())]);
773                req.build()
774            })
775            .await?;
776        decode_json_response::<Vec<Clip>>(resp).await
777    }
778
779    /// `GET /clips?...` — list clips with the given filter, newest-first.
780    /// Limit is clamped server-side; the client clamps to 200 to match the relay cap.
781    pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
782        let url = format!("{}/clips", self.base_url);
783        let resp = self
784            .send_with_retry(|| {
785                let mut req = self.client.get(&url).bearer_auth(&self.token);
786                let limit = if filter.limit == 0 {
787                    50
788                } else {
789                    filter.limit.min(200)
790                };
791                req = req.query(&[("limit", limit.to_string())]);
792                if let Some(s) = &filter.source {
793                    req = req.query(&[("source", s.as_str())]);
794                }
795                if let Some(s) = &filter.exclude_source {
796                    req = req.query(&[("exclude_source", s.as_str())]);
797                }
798                if filter.exclude_image {
799                    req = req.query(&[("exclude_image", "true")]);
800                }
801                if filter.exclude_text {
802                    req = req.query(&[("exclude_text", "true")]);
803                }
804                for id in &filter.clip_ids {
805                    req = req.query(&[("clip_id", id.as_str())]);
806                }
807                req.build()
808            })
809            .await?;
810        decode_json_response::<Vec<Clip>>(resp).await
811    }
812
813    /// `GET /clips?clip_id=<id>&limit=1` — fetch one clip by ID.
814    pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
815        let clips = self
816            .list_clips(ListClipsFilter {
817                limit: 1,
818                clip_ids: vec![clip_id.to_string()],
819                ..Default::default()
820            })
821            .await?;
822        clips.into_iter().next().ok_or_else(|| HttpError::Relay {
823            status: 404,
824            message: format!("Clip {} not found.", clip_id),
825            fix: String::new(),
826        })
827    }
828
829    /// `GET /clips/latest?exclude_source=<key>` — latest clip whose source != exclude_source.
830    pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
831        let url = format!("{}/clips/latest", self.base_url);
832        let resp = self
833            .send_with_retry(|| {
834                self.client
835                    .get(&url)
836                    .bearer_auth(&self.token)
837                    .query(&[("exclude_source", exclude_source)])
838                    .build()
839            })
840            .await?;
841        decode_json_response::<Clip>(resp).await
842    }
843
844    /// `DELETE /clips/{id}` — remove a clip. 404 is treated as success.
845    pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
846        let url = format!("{}/clips/{}", self.base_url, clip_id);
847        let resp = self
848            .send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
849            .await?;
850        let status = resp.status();
851        if status == StatusCode::NOT_FOUND || status.is_success() {
852            return Ok(());
853        }
854        if status == StatusCode::UNAUTHORIZED {
855            return Err(HttpError::Unauthorized);
856        }
857        Err(HttpError::Relay {
858            status: status.as_u16(),
859            message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
860            fix: String::new(),
861        })
862    }
863
864    /// `POST /clips/{id}/pin` — set or clear pin state. Best-effort: 404 treated as success.
865    pub async fn set_clip_pin(
866        &self,
867        clip_id: &str,
868        is_pinned: bool,
869        pin_note: Option<&str>,
870    ) -> Result<(), HttpError> {
871        let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
872        #[derive(serde::Serialize)]
873        struct PinBody<'a> {
874            is_pinned: bool,
875            #[serde(skip_serializing_if = "Option::is_none")]
876            pin_note: Option<&'a str>,
877        }
878        let body = PinBody {
879            is_pinned,
880            pin_note,
881        };
882        let resp = self
883            .send_with_retry(|| {
884                self.client
885                    .post(&url)
886                    .bearer_auth(&self.token)
887                    .json(&body)
888                    .build()
889            })
890            .await?;
891        let status = resp.status();
892        if status == StatusCode::NOT_FOUND || status.is_success() {
893            return Ok(());
894        }
895        if status == StatusCode::UNAUTHORIZED {
896            return Err(HttpError::Unauthorized);
897        }
898        Err(HttpError::Relay {
899            status: status.as_u16(),
900            message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
901            fix: String::new(),
902        })
903    }
904
905    /// `GET /devices` — list of paired devices for the current user.
906    pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
907        let url = format!("{}/devices", self.base_url);
908        let resp = self
909            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
910            .await?;
911        decode_json_response::<Vec<DeviceInfo>>(resp).await
912    }
913
914    async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
915    where
916        F: Fn() -> Result<reqwest::Request, reqwest::Error>,
917    {
918        let mut last_err: Option<HttpError> = None;
919        for attempt in 0..MAX_ATTEMPTS {
920            if attempt > 0 {
921                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
922            }
923            let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
924            match self.client.execute(req).await {
925                Ok(resp) => return Ok(resp),
926                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
927            }
928        }
929        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
930    }
931}
932
933async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
934    decode_json_response::<PushResponse>(resp).await
935}
936
937async fn decode_json_response<T: serde::de::DeserializeOwned>(
938    resp: reqwest::Response,
939) -> Result<T, HttpError> {
940    let status = resp.status();
941    if status == StatusCode::UNAUTHORIZED {
942        return Err(HttpError::Unauthorized);
943    }
944    if !status.is_success() {
945        let err: ErrorResponse = resp.json().await.unwrap_or_default();
946        let message = if !err.message.is_empty() {
947            err.message
948        } else {
949            err.error
950        };
951        return Err(HttpError::Relay {
952            status: status.as_u16(),
953            message,
954            fix: err.fix,
955        });
956    }
957    resp.json::<T>()
958        .await
959        .map_err(|e| HttpError::Decode(e.to_string()))
960}
961
962#[cfg(test)]
963mod tests {
964    use super::{HttpError, RestClient};
965    use crate::proto::cinch::v1::DeviceCodeStartRequest;
966    use crate::version::ClientInfo;
967    use wiremock::matchers::{body_json, header, method, path};
968    use wiremock::{Mock, MockServer, ResponseTemplate};
969
970    #[test]
971    fn device_code_start_request_includes_user_hint_when_set() {
972        let req = DeviceCodeStartRequest {
973            hostname: Some("dev-box-3".into()),
974            machine_id: Some("m1".into()),
975            user_hint: Some("alice@example.com".into()),
976        };
977        let bytes = serde_json::to_vec(&req).unwrap();
978        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
979        assert_eq!(parsed["user_hint"], "alice@example.com");
980    }
981
982    #[test]
983    fn device_code_start_request_omits_user_hint_when_none() {
984        let req = DeviceCodeStartRequest {
985            hostname: Some("dev-box-3".into()),
986            machine_id: Some("m1".into()),
987            user_hint: None,
988        };
989        let bytes = serde_json::to_vec(&req).unwrap();
990        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
991        assert!(
992            parsed.get("user_hint").is_none(),
993            "user_hint must omit when None"
994        );
995    }
996
997    #[tokio::test]
998    async fn set_display_name_happy_path() {
999        let server = MockServer::start().await;
1000        Mock::given(method("POST"))
1001            .and(path("/auth/display-name"))
1002            .and(header("authorization", "Bearer testtok"))
1003            .and(body_json(serde_json::json!({"display_name": "Alice"})))
1004            .respond_with(
1005                ResponseTemplate::new(200)
1006                    .set_body_json(serde_json::json!({"ok": true, "display_name": "Alice"})),
1007            )
1008            .mount(&server)
1009            .await;
1010
1011        let client = RestClient::new(server.uri(), "testtok", ClientInfo::for_test()).unwrap();
1012
1013        let stored = client.set_display_name("Alice").await.expect("ok");
1014        assert_eq!(stored, "Alice");
1015    }
1016
1017    #[tokio::test]
1018    async fn set_display_name_rejects_empty() {
1019        let client = RestClient::new("http://unused", "t", ClientInfo::for_test()).unwrap();
1020        let err = client.set_display_name("").await.expect_err("must reject");
1021        assert!(matches!(err, HttpError::Relay { status: 400, .. }));
1022    }
1023
1024    #[tokio::test]
1025    async fn set_display_name_propagates_unauthorized() {
1026        let server = MockServer::start().await;
1027        Mock::given(method("POST"))
1028            .and(path("/auth/display-name"))
1029            .respond_with(ResponseTemplate::new(401))
1030            .mount(&server)
1031            .await;
1032        let client = RestClient::new(server.uri(), "t", ClientInfo::for_test()).unwrap();
1033        let err = client.set_display_name("Bob").await.expect_err("401");
1034        assert!(matches!(err, HttpError::Unauthorized));
1035    }
1036}