use std::time::Duration;
use reqwest::{multipart, Client, StatusCode};
use crate::protocol::{Clip, DeviceInfo};
use crate::rest::{
DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
PullResponse, PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
};
const MAX_ATTEMPTS: u32 = 3;
const REQUEST_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Default, Clone)]
pub struct ListClipsFilter {
pub limit: u32,
pub source: Option<String>,
pub exclude_source: Option<String>,
pub exclude_image: bool,
pub exclude_text: bool,
pub clip_ids: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum HttpError {
#[error("network: {0}")]
Network(String),
#[error("auth required (401)")]
Unauthorized,
#[error("relay error ({status}): {message}")]
Relay {
status: u16,
message: String,
fix: String,
},
#[error("decode response: {0}")]
Decode(String),
#[error("build request: {0}")]
Build(String),
}
#[derive(Debug, Clone)]
pub struct RestClient {
base_url: String,
token: String,
client: Client,
}
impl RestClient {
pub fn new(relay_url: impl Into<String>, token: impl Into<String>) -> Result<Self, HttpError> {
let base = relay_url.into().trim_end_matches('/').to_string();
let client = Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
.map_err(|e| HttpError::Build(e.to_string()))?;
Ok(Self {
base_url: base,
token: token.into(),
client,
})
}
pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
let url = format!("{}/clips", self.base_url);
let resp = self
.send_with_retry(|| {
self.client
.post(&url)
.bearer_auth(&self.token)
.json(req)
.build()
})
.await?;
decode_push_response(resp).await
}
pub async fn push_clip_binary(
&self,
data: Vec<u8>,
content_type: &str,
source: &str,
label: Option<&str>,
target_device_id: Option<&str>,
) -> Result<PushResponse, HttpError> {
let url = format!("{}/clips/binary", self.base_url);
let mut last_err: Option<HttpError> = None;
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
}
let mut form = multipart::Form::new()
.part(
"file",
multipart::Part::bytes(data.clone()).file_name("upload"),
)
.text("content_type", content_type.to_string())
.text("source", source.to_string());
if let Some(l) = label.filter(|s| !s.is_empty()) {
form = form.text("label", l.to_string());
}
if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
form = form.text("target_device_id", d.to_string());
}
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.multipart(form)
.send()
.await;
match resp {
Ok(r) => return decode_push_response(r).await,
Err(e) => last_err = Some(HttpError::Network(e.to_string())),
}
}
Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
}
pub async fn pull_clipboard(&self) -> Result<PullResponse, HttpError> {
let url = format!("{}/pull", self.base_url);
let resp = self
.send_with_retry(|| self.client.post(&url).bearer_auth(&self.token).build())
.await?;
decode_json_response::<PullResponse>(resp).await
}
pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
let url = format!("{}/clips/latest", self.base_url);
let resp = self
.send_with_retry(|| {
self.client
.get(&url)
.bearer_auth(&self.token)
.query(&[("source", source)])
.build()
})
.await?;
decode_json_response::<Clip>(resp).await
}
pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
let url = format!("{}/clips/{}/media", self.base_url, clip_id);
let resp = self
.send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
.await?;
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
if !status.is_success() {
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
fix: String::new(),
});
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| HttpError::Decode(e.to_string()))
}
pub async fn start_device_code(
&self,
relay_url: &str,
hostname: &str,
machine_id: &str,
user_hint: Option<&str>,
) -> Result<DeviceCodeResponse, HttpError> {
let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
let req = DeviceCodeRequest {
hostname: Some(hostname.to_string()),
machine_id: if machine_id.is_empty() {
None
} else {
Some(machine_id.to_string())
},
user_hint: user_hint.map(|s| s.to_string()),
};
let resp = self
.client
.post(&url)
.json(&req)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
decode_json_response::<DeviceCodeResponse>(resp).await
}
pub async fn poll_device_code(
&self,
relay_url: &str,
device_code: &str,
) -> Result<DeviceCodePollResponse, HttpError> {
let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
let resp = self
.client
.get(&url)
.query(&[("code", device_code)])
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
decode_json_response::<DeviceCodePollResponse>(resp).await
}
pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
let url = format!("{}/auth/device-code/complete", self.base_url);
let body = DeviceCodeCompleteRequest {
user_code: user_code.to_string(),
user_id: String::new(),
device_id: String::new(),
token: String::new(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
decode_json_response::<serde_json::Value>(resp)
.await
.map(|_| ())
}
pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
let body = DeviceCodeDenyRequest {
user_code: user_code.to_string(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
decode_json_response::<serde_json::Value>(resp)
.await
.map(|_| ())
}
pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
let url = format!("{}/health", relay_url.trim_end_matches('/'));
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(HttpError::Relay {
status: resp.status().as_u16(),
message: format!("health check failed: HTTP {}", resp.status().as_u16()),
fix: String::new(),
})
}
}
pub async fn post_key_bundle(
&self,
target_device_id: &str,
ephemeral_public_key: &str,
encrypted_bundle: &str,
) -> Result<(), HttpError> {
let url = format!("{}/auth/key-bundle", self.base_url);
let body = KeyBundlePutRequest {
device_id: target_device_id.to_string(),
ephemeral_public_key: ephemeral_public_key.to_string(),
encrypted_bundle: encrypted_bundle.to_string(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
if !status.is_success() {
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("post key bundle failed: HTTP {}", status.as_u16()),
fix: String::new(),
});
}
Ok(())
}
pub async fn register_device_public_key(
&self,
public_key: &str,
fingerprint: &str,
) -> Result<(), HttpError> {
let url = format!("{}/auth/device/public-key", self.base_url);
let body = RegisterDevicePublicKeyRequest {
public_key: public_key.to_string(),
fingerprint: fingerprint.to_string(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
if !status.is_success() {
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("register public key failed: HTTP {}", status.as_u16()),
fix: String::new(),
});
}
Ok(())
}
pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
let url = format!("{}/auth/key-bundle/retry", self.base_url);
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
if !status.is_success() {
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
fix: String::new(),
});
}
Ok(())
}
pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
let url = format!("{}/auth/device/revoke", self.base_url);
let body = DeviceRevokeRequest {
device_id: device_id.to_string(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("revoke failed: HTTP {}", status.as_u16()),
fix: String::new(),
});
}
Ok(())
}
pub async fn set_device_nickname(
&self,
device_id: &str,
nickname: &str,
) -> Result<(), HttpError> {
let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
#[derive(serde::Serialize)]
struct NicknameBody<'a> {
nickname: &'a str,
}
let resp = self
.client
.put(&url)
.bearer_auth(&self.token)
.json(&NicknameBody { nickname })
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("set_device_nickname failed: {}", body),
fix: String::new(),
});
}
Ok(())
}
pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
let url = format!("{}/devices/self/retention", self.base_url);
#[derive(serde::Serialize)]
struct Body {
remote_retention_days: i32,
}
let resp = self
.client
.put(&url)
.bearer_auth(&self.token)
.json(&Body {
remote_retention_days: days,
})
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(HttpError::Relay {
status: status.as_u16(),
message: format!("set_remote_retention failed: {}", body),
fix: String::new(),
});
}
Ok(())
}
pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
let url = format!("{}/auth/key-bundle", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| HttpError::Network(e.to_string()))?;
decode_json_response::<KeyBundleResponse>(resp).await
}
pub async fn list_clips_since(
&self,
since: Option<chrono::DateTime<chrono::Utc>>,
limit: u32,
) -> Result<Vec<Clip>, HttpError> {
let url = format!("{}/clips", self.base_url);
let resp = self
.send_with_retry(|| {
let mut req = self.client.get(&url).bearer_auth(&self.token);
if let Some(ts) = since {
req = req.query(&[("since", ts.to_rfc3339())]);
}
req = req.query(&[("limit", limit.to_string())]);
req.build()
})
.await?;
decode_json_response::<Vec<Clip>>(resp).await
}
pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
let url = format!("{}/clips", self.base_url);
let resp = self
.send_with_retry(|| {
let mut req = self.client.get(&url).bearer_auth(&self.token);
let limit = if filter.limit == 0 {
50
} else {
filter.limit.min(200)
};
req = req.query(&[("limit", limit.to_string())]);
if let Some(s) = &filter.source {
req = req.query(&[("source", s.as_str())]);
}
if let Some(s) = &filter.exclude_source {
req = req.query(&[("exclude_source", s.as_str())]);
}
if filter.exclude_image {
req = req.query(&[("exclude_image", "true")]);
}
if filter.exclude_text {
req = req.query(&[("exclude_text", "true")]);
}
for id in &filter.clip_ids {
req = req.query(&[("clip_id", id.as_str())]);
}
req.build()
})
.await?;
decode_json_response::<Vec<Clip>>(resp).await
}
pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
let clips = self
.list_clips(ListClipsFilter {
limit: 1,
clip_ids: vec![clip_id.to_string()],
..Default::default()
})
.await?;
clips.into_iter().next().ok_or_else(|| HttpError::Relay {
status: 404,
message: format!("Clip {} not found.", clip_id),
fix: String::new(),
})
}
pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
let url = format!("{}/clips/latest", self.base_url);
let resp = self
.send_with_retry(|| {
self.client
.get(&url)
.bearer_auth(&self.token)
.query(&[("exclude_source", exclude_source)])
.build()
})
.await?;
decode_json_response::<Clip>(resp).await
}
pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
let url = format!("{}/clips/{}", self.base_url, clip_id);
let resp = self
.send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
.await?;
let status = resp.status();
if status == StatusCode::NOT_FOUND || status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
Err(HttpError::Relay {
status: status.as_u16(),
message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
fix: String::new(),
})
}
pub async fn set_clip_pin(
&self,
clip_id: &str,
is_pinned: bool,
pin_note: Option<&str>,
) -> Result<(), HttpError> {
let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
#[derive(serde::Serialize)]
struct PinBody<'a> {
is_pinned: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pin_note: Option<&'a str>,
}
let body = PinBody {
is_pinned,
pin_note,
};
let resp = self
.send_with_retry(|| {
self.client
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.build()
})
.await?;
let status = resp.status();
if status == StatusCode::NOT_FOUND || status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
Err(HttpError::Relay {
status: status.as_u16(),
message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
fix: String::new(),
})
}
pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
let url = format!("{}/devices", self.base_url);
let resp = self
.send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
.await?;
decode_json_response::<Vec<DeviceInfo>>(resp).await
}
async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
where
F: Fn() -> Result<reqwest::Request, reqwest::Error>,
{
let mut last_err: Option<HttpError> = None;
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
}
let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
match self.client.execute(req).await {
Ok(resp) => return Ok(resp),
Err(e) => last_err = Some(HttpError::Network(e.to_string())),
}
}
Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
}
}
async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
decode_json_response::<PushResponse>(resp).await
}
async fn decode_json_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, HttpError> {
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(HttpError::Unauthorized);
}
if !status.is_success() {
let err: ErrorResponse = resp.json().await.unwrap_or_default();
let message = if !err.message.is_empty() {
err.message
} else {
err.error
};
return Err(HttpError::Relay {
status: status.as_u16(),
message,
fix: err.fix,
});
}
resp.json::<T>()
.await
.map_err(|e| HttpError::Decode(e.to_string()))
}
#[cfg(test)]
mod tests {
use crate::proto::cinch::v1::DeviceCodeStartRequest;
#[test]
fn device_code_start_request_includes_user_hint_when_set() {
let req = DeviceCodeStartRequest {
hostname: Some("dev-box-3".into()),
machine_id: Some("m1".into()),
user_hint: Some("alice@example.com".into()),
};
let bytes = serde_json::to_vec(&req).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["user_hint"], "alice@example.com");
}
#[test]
fn device_code_start_request_omits_user_hint_when_none() {
let req = DeviceCodeStartRequest {
hostname: Some("dev-box-3".into()),
machine_id: Some("m1".into()),
user_hint: None,
};
let bytes = serde_json::to_vec(&req).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
parsed.get("user_hint").is_none(),
"user_hint must omit when None"
);
}
}