use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Runtime, State};
use crate::machine::AckOutcome;
use crate::runtime::{BundleSource, CurrentBundle, HotUpdate};
use crate::update::{UpdateConfig, UpdateOutcome};
use crate::{Error, Result};
pub const PROGRESS_EVENT: &str = "hot-update://progress";
const PROGRESS_MIN_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub downloaded: u64,
pub total: u64,
}
pub(crate) struct CommandConfig {
pub(crate) update: Option<UpdateConfig>,
}
impl CommandConfig {
fn update_config(&self) -> Result<&UpdateConfig> {
self.update.as_ref().ok_or(Error::Disabled)
}
fn is_disabled(&self) -> bool {
self.update.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub(crate) enum AckResult {
Committed { seq: u64 },
AlreadyCommitted { seq: u64 },
EmbeddedNoop,
Stale { seq: u64 },
}
impl From<AckOutcome> for AckResult {
fn from(outcome: AckOutcome) -> Self {
match outcome {
AckOutcome::Committed(seq) => Self::Committed { seq },
AckOutcome::AlreadyCommitted(seq) => Self::AlreadyCommitted { seq },
AckOutcome::EmbeddedNoop => Self::EmbeddedNoop,
AckOutcome::Stale(seq) => Self::Stale { seq },
}
}
}
pub(crate) struct ProgressThrottle {
min_interval: Duration,
last_emit: Option<Instant>,
}
impl ProgressThrottle {
pub(crate) fn new(min_interval: Duration) -> Self {
Self {
min_interval,
last_emit: None,
}
}
pub(crate) fn should_emit(&mut self, downloaded: u64, total: u64) -> bool {
let is_final = downloaded >= total;
let due = self
.last_emit
.map_or(true, |at| at.elapsed() >= self.min_interval);
if is_final || due {
self.last_emit = Some(Instant::now());
return true;
}
false
}
}
#[tauri::command]
pub(crate) async fn check(
hot_update: State<'_, HotUpdate>,
config: State<'_, CommandConfig>,
) -> Result<UpdateOutcome> {
hot_update.check(config.update_config()?).await
}
#[tauri::command]
pub(crate) async fn download<R: Runtime>(
app: AppHandle<R>,
hot_update: State<'_, HotUpdate>,
config: State<'_, CommandConfig>,
) -> Result<UpdateOutcome> {
let mut throttle = ProgressThrottle::new(PROGRESS_MIN_INTERVAL);
hot_update
.check_and_download(config.update_config()?, |downloaded, total| {
if throttle.should_emit(downloaded, total) {
if let Err(e) = app.emit(PROGRESS_EVENT, DownloadProgress { downloaded, total }) {
log::warn!("hot-update: failed to emit {PROGRESS_EVENT}: {e}");
}
}
})
.await
}
#[tauri::command]
pub(crate) fn notify_app_ready(
hot_update: State<'_, HotUpdate>,
config: State<'_, CommandConfig>,
) -> Result<AckResult> {
if config.is_disabled() {
return Ok(AckResult::EmbeddedNoop);
}
Ok(hot_update.notify_app_ready()?.into())
}
#[tauri::command]
pub(crate) fn current_bundle<R: Runtime>(
app: AppHandle<R>,
hot_update: State<'_, HotUpdate>,
config: State<'_, CommandConfig>,
) -> Result<CurrentBundle> {
if config.is_disabled() {
return Ok(CurrentBundle {
source: BundleSource::Embedded,
seq: None,
version: app.package_info().version.clone(),
});
}
hot_update.current_bundle()
}
#[tauri::command]
pub(crate) fn reset(
hot_update: State<'_, HotUpdate>,
config: State<'_, CommandConfig>,
) -> Result<()> {
if config.is_disabled() {
return Ok(());
}
hot_update.reset()
}
#[cfg(test)]
mod tests;