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