use std::time::Duration;
use crate::config::CortexConfig;
use crate::error::{SdkError, SdkResult};
use crate::models::*;
pub struct CortexClient {
config: CortexConfig,
http: reqwest::Client,
heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
}
impl CortexClient {
pub fn new(config: CortexConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout))
.build()
.expect("Failed to create HTTP client");
let mut client = Self {
config,
http,
heartbeat_handle: None,
};
client.start_heartbeat();
client
}
fn bearer(&self) -> String {
format!("Bearer {}", self.config.pat)
}
fn auth_get(&self, url: &str) -> reqwest::RequestBuilder {
self.http.get(url).header("Authorization", self.bearer())
}
fn auth_post(&self, url: &str, body: &impl serde::Serialize) -> reqwest::RequestBuilder {
self.http
.post(url)
.header("Authorization", self.bearer())
.json(body)
}
async fn decode<T: serde::de::DeserializeOwned>(&self, resp: reqwest::Response) -> SdkResult<T> {
let api_resp: ApiResponse<T> = resp.json().await?;
if !api_resp.success {
return Err(SdkError::ServerError(api_resp.message.unwrap_or_default()));
}
api_resp
.data
.ok_or_else(|| SdkError::ServerError("No data".to_string()))
}
pub async fn list_software(&self) -> SdkResult<Vec<SoftwareStoreItem>> {
let url = format!("{}/api/softwares/store", self.config.server_url);
let resp = self.auth_get(&url).send().await?;
self.decode(resp).await
}
pub async fn get_latest_version(&self, software_id: &str) -> SdkResult<SoftwareVersion> {
let url = format!(
"{}/api/softwares/{}/latest-version",
self.config.server_url, software_id
);
let resp = self.auth_get(&url).send().await?;
self.decode(resp).await
}
pub async fn download(&self, version_id: &str) -> SdkResult<DownloadInfo> {
let url = format!(
"{}/api/softwares/sdk-download/{}",
self.config.server_url, version_id
);
let resp = self.auth_post(&url, &serde_json::json!({})).send().await?;
self.decode(resp).await
}
pub async fn check_update(
&self,
platform: &str,
current_version: &str,
channel: Option<&str>,
) -> SdkResult<UpdateInfo> {
let url = format!("{}/api/softwares/sdk-check-update", self.config.server_url);
let body = serde_json::json!({
"software_id": self.config.software_id,
"platform": platform,
"channel": channel.unwrap_or("stable"),
"current_version": current_version,
});
let resp = self.auth_post(&url, &body).send().await?;
self.decode(resp).await
}
pub async fn open_update_events(
&self,
software_id: Option<&str>,
last_event_id: Option<u64>,
) -> SdkResult<crate::sse::UpdateEventStream> {
let stream_client = reqwest::Client::builder().build()?;
let url = format!("{}/api/softwares/sdk-update-events", self.config.server_url);
let mut req = stream_client
.get(&url)
.header("Authorization", self.bearer())
.header("Accept", "text/event-stream");
if let Some(sid) = software_id {
req = req.query(&[("software_id", sid)]);
}
if let Some(last) = last_event_id {
req = req.header("Last-Event-ID", last.to_string());
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(SdkError::ServerError(format!(
"SSE connect failed: HTTP {}",
resp.status()
)));
}
let stream = futures_util::StreamExt::map(resp.bytes_stream(), |r| {
r.map(|b| b.to_vec())
});
Ok(crate::sse::UpdateEventStream::new(stream))
}
pub async fn list_devices(&self) -> SdkResult<Vec<DeviceRecord>> {
let url = format!("{}/api/devices/active", self.config.server_url);
let resp = self.auth_get(&url).send().await?;
self.decode(resp).await
}
pub async fn unbind_device(&self, device_id: &str) -> SdkResult<()> {
let url = format!("{}/api/devices/{}/unbind", self.config.server_url, device_id);
let resp = self.auth_post(&url, &serde_json::json!({})).send().await?;
let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
if !api_resp.success {
return Err(SdkError::ServerError(api_resp.message.unwrap_or_default()));
}
Ok(())
}
pub async fn fetch_offline_license(&self, fingerprint: &str) -> SdkResult<OfflineLicenseFile> {
let url = format!("{}/api/offline-licenses/issue", self.config.server_url);
let body = serde_json::json!({
"softwareId": self.config.software_id,
"fingerprint": fingerprint,
});
let resp = self.auth_post(&url, &body).send().await?;
self.decode(resp).await
}
pub fn verify_offline_license(
&self,
file: &OfflineLicenseFile,
expected_fingerprint: &str,
) -> SdkResult<OfflineLicensePayload> {
verify_offline_license(
file,
&self.config.software_id,
expected_fingerprint,
self.config.software_public_key.as_deref(),
)
}
fn start_heartbeat(&mut self) {
if let Some(h) = self.heartbeat_handle.take() {
h.abort();
}
if self.config.heartbeat_interval == 0 {
return;
}
let http = self.http.clone();
let config = self.config.clone();
let interval = self.config.heartbeat_interval;
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(interval));
loop {
ticker.tick().await;
let dev = crate::device::collect();
let url = format!("{}/api/devices/heartbeat", config.server_url);
let _ = http
.post(&url)
.header("Authorization", format!("Bearer {}", config.pat))
.json(&serde_json::json!({
"softwareId": config.software_id,
"fingerprint": dev.fingerprint,
"deviceInfo": dev.info,
}))
.send()
.await;
}
});
self.heartbeat_handle = Some(handle);
}
}
impl Drop for CortexClient {
fn drop(&mut self) {
if let Some(h) = self.heartbeat_handle.take() {
h.abort();
}
}
}
pub fn verify_offline_license(
file: &OfflineLicenseFile,
expected_software_id: &str,
expected_fingerprint: &str,
pinned_public_key: Option<&str>,
) -> SdkResult<OfflineLicensePayload> {
if let Some(pinned) = pinned_public_key {
if pinned.trim() != file.public_key.trim() {
return Err(SdkError::LicenseFieldMismatch(format!(
"public_key mismatch: pinned {:?} got {:?}",
pinned, file.public_key
)));
}
}
let pub_bytes = hex::decode(&file.public_key).map_err(|e| {
SdkError::Signature(format!("invalid public_key hex: {e}"))
})?;
if pub_bytes.len() != 32 {
return Err(SdkError::Signature(format!(
"public_key must be 32 bytes, got {}",
pub_bytes.len()
)));
}
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pub_bytes);
let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| SdkError::Signature(format!("invalid verifying key: {e}")))?;
let sig_bytes = hex::decode(&file.signature).map_err(|e| {
SdkError::Signature(format!("invalid signature hex: {e}"))
})?;
if sig_bytes.len() != 64 {
return Err(SdkError::Signature(format!(
"signature must be 64 bytes, got {}",
sig_bytes.len()
)));
}
let sig = ed25519_dalek::Signature::from_slice(&sig_bytes)
.map_err(|e| SdkError::Signature(format!("invalid signature: {e}")))?;
use ed25519_dalek::Verifier;
vk
.verify(file.payload.as_bytes(), &sig)
.map_err(|_| SdkError::Signature("signature verification failed".to_string()))?;
let payload: OfflineLicensePayload = serde_json::from_str(&file.payload).map_err(|e| {
SdkError::Signature(format!("invalid payload json: {e}"))
})?;
if payload.software_id != expected_software_id {
return Err(SdkError::LicenseFieldMismatch(format!(
"software_id mismatch: expected {expected_software_id:?} got {:?}",
payload.software_id
)));
}
if payload.fingerprint != expected_fingerprint {
return Err(SdkError::LicenseFieldMismatch(format!(
"fingerprint mismatch: expected {expected_fingerprint:?} got {:?}",
payload.fingerprint
)));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if payload.expire_time <= now {
return Err(SdkError::LicenseExpired);
}
Ok(payload)
}