//! Client side of cdxs state synchronization. //! //! Sync only exchanges portable cdxs state. Server credentials stay on the //! server, and the client's sync token remains local. use anyhow::{anyhow, Context, Result}; use chrono::Utc; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use serde::{Deserialize, Serialize}; use crate::config_store::Store; #[derive(Debug, Serialize)] struct LoginRequest<'a> { username: &'a str, password: &'a str, } #[derive(Debug, Deserialize)] struct LoginResponse { token: String, } pub async fn login(server: &str, user: &str, password: &str) -> Result<()> { // Normalize the server URL once so later pull/push can simply append API // paths without double slashes. let server = server.trim().trim_end_matches('/'); if server.is_empty() { return Err(anyhow!("server URL 不能为空")); } let response = reqwest::Client::new() .post(format!("{server}/v1/login")) .json(&LoginRequest { username: user, password, }) .send() .await .context("sync login 请求失败")?; let status = response.status(); let body = response.text().await.context("读取 sync login 响应失败")?; if !status.is_success() { return Err(anyhow!("sync login 失败: status={}, body={}", status, body)); } let login: LoginResponse = serde_json::from_str(&body).context("解析 sync login 响应失败")?; let home = crate::paths::codex_home(None)?; let mut store = Store::load(&home)?; store.sync.server_url = Some(server.to_string()); store.sync.username = Some(user.to_string()); store.sync.token = Some(login.token); store.save(&home)?; println!("sync login 成功: {server} ({user})"); Ok(()) } pub async fn pull() -> Result<()> { let home = crate::paths::codex_home(None)?; let mut local = Store::load(&home)?; let (server, token) = sync_endpoint(&local)?; let remote = reqwest::Client::new() .get(format!("{server}/v1/state")) .headers(auth_headers(&token)?) .send() .await .context("sync pull 请求失败")?; let status = remote.status(); let body = remote.text().await.context("读取 sync pull 响应失败")?; if !status.is_success() { return Err(anyhow!("sync pull 失败: status={}, body={}", status, body)); } let remote_store: Store = serde_json::from_str(&body).context("解析 sync pull 响应失败")?; // Pull replaces local portable state, but keeps local sync endpoint/token. local.meta = remote_store.meta; local.accounts = remote_store.accounts; local.homes = remote_store.homes; local.sync.last_pull_at = Some(Utc::now().timestamp()); local.save(&home)?; println!("sync pull 完成"); Ok(()) } pub async fn push() -> Result<()> { let home = crate::paths::codex_home(None)?; let mut local = Store::load(&home)?; let (server, token) = sync_endpoint(&local)?; let mut payload = local.clone(); // Do not upload local server users or sync token back into the shared state. payload.server = Default::default(); payload.sync = Default::default(); let response = reqwest::Client::new() .put(format!("{server}/v1/state")) .headers(auth_headers(&token)?) .json(&payload) .send() .await .context("sync push 请求失败")?; let status = response.status(); let body = response.text().await.context("读取 sync push 响应失败")?; if !status.is_success() { return Err(anyhow!("sync push 失败: status={}, body={}", status, body)); } local.sync.last_push_at = Some(Utc::now().timestamp()); local.save(&home)?; println!("sync push 完成"); Ok(()) } pub fn status() -> Result<()> { let home = crate::paths::codex_home(None)?; let store = Store::load(&home)?; println!( "server: {}", store.sync.server_url.as_deref().unwrap_or("-") ); println!("user: {}", store.sync.username.as_deref().unwrap_or("-")); println!( "token: {}", if store.sync.token.as_deref().is_some() { "" } else { "-" } ); println!( "last_pull_at: {}", store .sync .last_pull_at .map(|v| v.to_string()) .unwrap_or_else(|| "-".to_string()) ); println!( "last_push_at: {}", store .sync .last_push_at .map(|v| v.to_string()) .unwrap_or_else(|| "-".to_string()) ); Ok(()) } fn sync_endpoint(store: &Store) -> Result<(String, String)> { // Centralize validation so pull and push produce the same user-facing errors. let server = store .sync .server_url .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("未登录 sync server,请先运行 sync login"))? .trim_end_matches('/') .to_string(); let token = store .sync .token .clone() .ok_or_else(|| anyhow!("缺少 sync token,请重新 sync login"))?; Ok((server, token)) } fn auth_headers(token: &str) -> Result { let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}")).context("构建 Authorization 头失败")?, ); Ok(headers) }