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