Admin 386ca99889
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB (push) Has been cancelled
Desktop Release / Publish tagged desktop release (push) Has been cancelled
deploy: sync FieldLens Print and camera lifecycle
2026-09-23 21:31:42 +00:00

331 lines
9.5 KiB
Rust

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<String>,
expires_in: u64,
#[serde(default = "default_interval")]
interval: u64,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
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> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("create HTTP client")
}
pub fn begin_login(state: &SharedState) -> Result<LoginStart> {
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<LoginPoll> {
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::<OAuthError>()
.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<String> {
{
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<String> {
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<SyncResult> {
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<ScanEvent> = pending.into_iter().take(100).collect();
let events: Vec<Value> = 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<String> = 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<String> {
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<Response> {
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}")
}
))
}