use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; use reqwest::blocking::{Client, Response}; use serde::Deserialize; use serde_json::{json, Value}; use crate::{ model::{LoginPoll, LoginStart, ScanEvent, SyncResult}, state::SharedState, }; #[derive(Debug, Deserialize)] struct DeviceCodeResponse { device_code: String, user_code: String, verification_uri: String, verification_uri_complete: Option, expires_in: u64, #[serde(default = "default_interval")] interval: u64, } #[derive(Debug, Deserialize)] struct TokenResponse { access_token: String, #[serde(default)] refresh_token: Option, expires_in: i64, } #[derive(Debug, Deserialize)] struct OAuthError { error: String, } fn default_interval() -> u64 { 5 } fn now_unix() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } fn client() -> Result { Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .context("create HTTP client") } pub fn begin_login(state: &SharedState) -> Result { let config = state.config.lock().clone(); let endpoint = format!( "{}/protocol/openid-connect/auth/device", config.issuer.trim_end_matches('/') ); let response = client()? .post(endpoint) .form(&[("client_id", config.client_id.as_str())]) .send() .context("request Keycloak device code")?; let response = ensure_success(response, "device authorization")?; let body: DeviceCodeResponse = response.json().context("decode device authorization response")?; { let mut auth = state.auth.lock(); auth.device_code = Some(body.device_code); auth.poll_interval = body.interval; auth.access_token = None; auth.refresh_token = None; auth.expires_at_unix = None; } Ok(LoginStart { user_code: body.user_code, verification_uri: body.verification_uri, verification_uri_complete: body.verification_uri_complete, expires_in: body.expires_in, interval: body.interval, }) } pub fn poll_login(state: &SharedState) -> Result { let config = state.config.lock().clone(); let device_code = state .auth .lock() .device_code .clone() .ok_or_else(|| anyhow!("no device login is active"))?; let endpoint = format!( "{}/protocol/openid-connect/token", config.issuer.trim_end_matches('/') ); let response = client()? .post(endpoint) .form(&[ ("client_id", config.client_id.as_str()), ( "grant_type", "urn:ietf:params:oauth:grant-type:device_code", ), ("device_code", device_code.as_str()), ]) .send() .context("poll Keycloak device login")?; if response.status().is_success() { let token: TokenResponse = response.json().context("decode token response")?; let mut auth = state.auth.lock(); auth.access_token = Some(token.access_token); auth.refresh_token = token.refresh_token; auth.expires_at_unix = Some(now_unix() + token.expires_in); auth.device_code = None; return Ok(LoginPoll { status: "authorized".into(), }); } let status = response.status(); let error = response .json::() .map(|body| body.error) .unwrap_or_else(|_| format!("http_{}", status.as_u16())); let mapped = match error.as_str() { "authorization_pending" | "slow_down" => "pending", "expired_token" => "expired", "access_denied" => "denied", _ => return Err(anyhow!("device login failed: {error}")), }; Ok(LoginPoll { status: mapped.into(), }) } fn ensure_access_token(state: &SharedState) -> Result { { let auth = state.auth.lock(); if auth.expires_at_unix.unwrap_or_default() > now_unix() + 30 { if let Some(token) = &auth.access_token { return Ok(token.clone()); } } } refresh_access_token(state) } fn refresh_access_token(state: &SharedState) -> Result { let config = state.config.lock().clone(); let refresh_token = state .auth .lock() .refresh_token .clone() .ok_or_else(|| anyhow!("not signed in to FieldLens"))?; let endpoint = format!( "{}/protocol/openid-connect/token", config.issuer.trim_end_matches('/') ); let response = client()? .post(endpoint) .form(&[ ("client_id", config.client_id.as_str()), ("grant_type", "refresh_token"), ("refresh_token", refresh_token.as_str()), ]) .send() .context("refresh FieldLens token")?; let response = ensure_success(response, "token refresh")?; let token: TokenResponse = response.json().context("decode refresh response")?; let access_token = token.access_token.clone(); let mut auth = state.auth.lock(); auth.access_token = Some(token.access_token); if token.refresh_token.is_some() { auth.refresh_token = token.refresh_token; } auth.expires_at_unix = Some(now_unix() + token.expires_in); Ok(access_token) } pub fn sync_pending(state: &SharedState) -> Result { let pending = state.pending.lock().clone(); if pending.is_empty() { return Ok(SyncResult { synced: 0, pending: 0, }); } let access_token = ensure_access_token(state)?; let client = client()?; let device_id = ensure_device(&client, state, &access_token)?; let batch: Vec = pending.into_iter().take(100).collect(); let events: Vec = batch .iter() .map(|scan| { json!({ "id": scan.id, "aggregateId": scan.id, "eventType": "scan.captured", "schemaVersion": 1, "createdAt": scan.captured_at, "payload": { "id": scan.id, "rawValue": scan.raw_value, "format": scan.format, "duplicate": scan.duplicate, "capturedAt": scan.captured_at, "source": scan.source, "sourceMetadata": scan.source_metadata, "schemaVersion": 1 } }) }) .collect(); let config = state.config.lock().clone(); let response = client .post(format!( "{}/api/v1/sync", config.api_base_url.trim_end_matches('/') )) .bearer_auth(&access_token) .json(&json!({ "deviceId": device_id, "schemaVersion": 1, "events": events })) .send() .context("sync print scans")?; let response = ensure_success(response, "FieldLens sync")?; let body: Value = response.json().context("decode FieldLens sync response")?; let accepted: std::collections::HashSet = body .get("acknowledgements") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|ack| { let status = ack.get("status")?.as_str()?; if status != "accepted" && status != "duplicate" { return None; } ack.get("eventId")?.as_str().map(str::to_owned) }) .collect(); let synced = accepted.len(); { let mut queue = state.pending.lock(); queue.retain(|scan| !accepted.contains(&scan.id)); } state.persist_pending()?; Ok(SyncResult { synced, pending: state.pending_count(), }) } fn ensure_device(client: &Client, state: &SharedState, access_token: &str) -> Result { let config = state.config.lock().clone(); if let Some(device_id) = config.device_id.filter(|id| !id.trim().is_empty()) { return Ok(device_id); } let mut body = json!({ "installationId": config.installation_id, "label": "FieldLens Print", "appVersion": env!("CARGO_PKG_VERSION"), "schemaVersion": 1 }); if !config.station_id.trim().is_empty() { body["stationId"] = Value::String(config.station_id.clone()); } let response = client .post(format!( "{}/api/v1/devices/register", config.api_base_url.trim_end_matches('/') )) .bearer_auth(access_token) .json(&body) .send() .context("register FieldLens Print device")?; let response = ensure_success(response, "FieldLens device registration")?; let body: Value = response.json().context("decode device registration")?; let device_id = body .get("id") .and_then(Value::as_str) .ok_or_else(|| anyhow!("FieldLens device registration returned no id"))? .to_string(); state.config.lock().device_id = Some(device_id.clone()); state.persist_config()?; Ok(device_id) } fn ensure_success(response: Response, operation: &str) -> Result { if response.status().is_success() { return Ok(response); } let status = response.status(); let detail = response.text().unwrap_or_default(); Err(anyhow!( "{operation} failed with {}{}", status.as_u16(), if detail.is_empty() { String::new() } else { format!(": {detail}") } )) }