use super::{ci::is_ci, config::UpdateCheckConfig, instance_id::install_hash};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const LEGACY_ENTRY: &str = "library";
#[derive(Debug, Clone, Serialize)]
pub struct UpdateCheckPayload {
pub version: String,
pub os: String,
pub arch: String,
pub instance_hash: String,
pub edition: String,
pub entry: String,
pub ci: bool,
}
impl UpdateCheckPayload {
#[must_use]
pub fn new(instance_hash: String, edition: &str, entry: &str, ci: bool) -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
instance_hash,
edition: edition.to_string(),
entry: entry.to_string(),
ci,
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateCheckContext {
entry: String,
edition: String,
data_dir: Option<PathBuf>,
}
impl UpdateCheckContext {
#[must_use]
pub fn new(entry: impl Into<String>, edition: impl Into<String>) -> Self {
Self {
entry: entry.into(),
edition: edition.into(),
data_dir: None,
}
}
#[must_use]
pub fn with_data_dir(mut self, data_dir: impl Into<PathBuf>) -> Self {
self.data_dir = Some(data_dir.into());
self
}
#[must_use]
pub fn entry(&self) -> &str {
&self.entry
}
#[must_use]
pub fn edition(&self) -> &str {
&self.edition
}
#[must_use]
pub fn data_dir(&self) -> Option<&Path> {
self.data_dir.as_deref()
}
fn payload(&self) -> UpdateCheckPayload {
UpdateCheckPayload::new(
install_hash(self.data_dir.as_deref()),
&self.edition,
&self.entry,
is_ci(|key| std::env::var(key).ok()),
)
}
}
#[derive(Debug, Clone, Copy)]
enum Cadence {
Once,
Every(Duration),
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateCheckResponse {
pub latest_version: String,
pub update_available: bool,
pub message: Option<String>,
}
async fn drive<B, P, Fut>(
config: &UpdateCheckConfig,
env: impl Fn(&str) -> Option<String>,
cadence: Cadence,
build: B,
mut ping: P,
) where
B: FnOnce() -> UpdateCheckPayload,
P: FnMut(UpdateCheckPayload) -> Fut,
Fut: Future<Output = ()>,
{
if !config.is_enabled_with(env) {
tracing::debug!("Update check disabled by user preference");
return;
}
tracing::info!("Checking for updates... (disable with VELESDB_NO_UPDATE_CHECK=1)");
let payload = build();
ping(payload.clone()).await;
let Cadence::Every(interval) = cadence else {
return;
};
loop {
tokio::time::sleep(interval).await;
ping(payload.clone()).await;
}
}
async fn ping_server(config: &UpdateCheckConfig, payload: UpdateCheckPayload) {
tracing::debug!(?payload, "Update check payload");
let timeout = Duration::from_millis(config.timeout_ms);
match tokio::time::timeout(timeout, send_update_check(&config.endpoint, &payload)).await {
Ok(Ok(response)) => handle_response(&payload.version, &response),
Ok(Err(_)) => {
tracing::trace!("Update check skipped (network unavailable)");
}
Err(_) => {
tracing::trace!("Update check skipped (timeout)");
}
}
}
async fn run(config: &UpdateCheckConfig, context: &UpdateCheckContext, cadence: Cadence) {
drive(
config,
|key| std::env::var(key).ok(),
cadence,
|| context.payload(),
|payload| ping_server(config, payload),
)
.await;
}
pub async fn check_for_updates_with(config: &UpdateCheckConfig, context: &UpdateCheckContext) {
run(config, context, Cadence::Once).await;
}
#[deprecated(
since = "7.0.0",
note = "use `check_for_updates_with`, which reports the calling binary"
)]
pub async fn check_for_updates(config: &UpdateCheckConfig, data_dir: &Path, edition: &str) {
let context = UpdateCheckContext::new(LEGACY_ENTRY, edition).with_data_dir(data_dir);
check_for_updates_with(config, &context).await;
}
async fn send_update_check(
endpoint: &str,
payload: &UpdateCheckPayload,
) -> Result<UpdateCheckResponse, reqwest::Error> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5)) .build()?;
let response = client
.post(endpoint)
.json(payload)
.send()
.await?
.json::<UpdateCheckResponse>()
.await?;
Ok(response)
}
fn handle_response(current_version: &str, response: &UpdateCheckResponse) {
if response.update_available {
let message = response.message.as_deref().unwrap_or("");
tracing::info!(
"Update available: {} -> {} {}",
current_version,
response.latest_version,
message
);
} else {
tracing::debug!("VelesDB is up to date ({})", current_version);
}
}
fn spawn(config: UpdateCheckConfig, context: UpdateCheckContext, cadence: Cadence) {
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
match rt {
Ok(runtime) => runtime.block_on(run(&config, &context, cadence)),
Err(e) => {
tracing::trace!("Update check skipped (runtime error: {})", e);
}
}
});
}
pub fn spawn_update_check_with(config: UpdateCheckConfig, context: UpdateCheckContext) {
spawn(config, context, Cadence::Once);
}
pub fn spawn_update_heartbeat(config: UpdateCheckConfig, context: UpdateCheckContext) {
spawn(config, context, Cadence::Every(HEARTBEAT_INTERVAL));
}
#[deprecated(
since = "7.0.0",
note = "use `spawn_update_check_with`, which reports the calling binary"
)]
pub fn spawn_update_check(config: UpdateCheckConfig, data_dir: PathBuf, edition: String) {
let context = UpdateCheckContext::new(LEGACY_ENTRY, edition).with_data_dir(data_dir);
spawn_update_check_with(config, context);
}
#[cfg(test)]
#[path = "check_tests.rs"]
mod tests;