1use 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#[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#[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 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 pub fn client_info(&self) -> &ClientInfo {
160 &self.client_info
161 }
162
163 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}