diff --git a/crates/modbusmaster-app/src/update.rs b/crates/modbusmaster-app/src/update.rs index 8592b8b..960f4a6 100644 --- a/crates/modbusmaster-app/src/update.rs +++ b/crates/modbusmaster-app/src/update.rs @@ -1,15 +1,26 @@ use chrono::{DateTime, Duration, Utc}; use serde::Serialize; -use tauri::{AppHandle, Manager, State}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration as StdDuration; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_store::StoreExt; use tauri_plugin_updater::{Update, UpdaterExt}; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, time::timeout}; const STORE_FILE: &str = "update_state.json"; const KEY_LAST_CHECK: &str = "last_check_at"; const KEY_SKIPPED_VERSION: &str = "skipped_version"; const KEY_INSTALL_ON_NEXT_LAUNCH: &str = "install_on_next_launch"; const THROTTLE_HOURS: i64 = 6; +const CHECK_ENDPOINT_TIMEOUT: StdDuration = StdDuration::from_secs(10); +const CHECK_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30); +const DOWNLOAD_TIMEOUT: StdDuration = StdDuration::from_secs(5 * 60); +const PROGRESS_EVENT: &str = "update-progress"; +const CHECK_TIMEOUT_ERROR: &str = "UPDATE_CHECK_TIMEOUT"; +const DOWNLOAD_TIMEOUT_ERROR: &str = "UPDATE_DOWNLOAD_TIMEOUT"; #[derive(Serialize, Clone)] pub struct UpdateMeta { @@ -18,6 +29,14 @@ pub struct UpdateMeta { pub pub_date: Option, } +#[derive(Serialize, Clone)] +struct UpdateProgress { + stage: &'static str, + downloaded: u64, + total: Option, + percent: Option, +} + struct PreparedUpdate { meta: UpdateMeta, update: Update, @@ -75,14 +94,92 @@ fn update_meta(update: &Update) -> UpdateMeta { } } -async fn download_update(update: &Update) -> Result, String> { - update - .download( - |_, _| {}, - || log::info!("update download finished; verifying release signature"), - ) - .await - .map_err(|e| e.to_string()) +fn progress_percent(downloaded: u64, total: Option) -> Option { + total + .filter(|total| *total > 0) + .map(|total| ((downloaded.saturating_mul(100) / total).min(100)) as u8) +} + +fn emit_update_progress(app: &AppHandle, stage: &'static str, downloaded: u64, total: Option) { + let _ = app.emit( + PROGRESS_EVENT, + UpdateProgress { + stage, + downloaded, + total, + percent: progress_percent(downloaded, total), + }, + ); +} + +async fn find_update(app: &AppHandle) -> Result, String> { + emit_update_progress(app, "checking", 0, None); + let result = async { + let updater = app + .updater_builder() + .timeout(CHECK_ENDPOINT_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + timeout(CHECK_TOTAL_TIMEOUT, updater.check()) + .await + .map_err(|_| CHECK_TIMEOUT_ERROR.to_string())? + .map_err(|e| e.to_string()) + } + .await; + if result.is_err() { + emit_update_progress(app, "idle", 0, None); + } + result +} + +async fn download_update(app: &AppHandle, update: &Update) -> Result, String> { + emit_update_progress(app, "downloading", 0, None); + let progress_app = app.clone(); + let verify_app = app.clone(); + let downloaded = Arc::new(AtomicU64::new(0)); + let progress_downloaded = Arc::clone(&downloaded); + let verify_downloaded = Arc::clone(&downloaded); + let mut last_percent = None; + let mut last_emitted_bytes = 0_u64; + let download = update.download( + move |chunk_len, total| { + let downloaded = progress_downloaded + .fetch_add(chunk_len as u64, Ordering::Relaxed) + .saturating_add(chunk_len as u64); + let percent = progress_percent(downloaded, total); + let should_emit = percent != last_percent + || (total.is_none() && downloaded.saturating_sub(last_emitted_bytes) >= 256 * 1024); + if should_emit { + emit_update_progress(&progress_app, "downloading", downloaded, total); + last_percent = percent; + last_emitted_bytes = downloaded; + } + }, + move || { + emit_update_progress( + &verify_app, + "verifying", + verify_downloaded.load(Ordering::Relaxed), + None, + ); + log::info!("update download finished; verifying release signature"); + }, + ); + + let result = match timeout(DOWNLOAD_TIMEOUT, download).await { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err(DOWNLOAD_TIMEOUT_ERROR.to_string()), + }; + match result { + Ok(bytes) => { + emit_update_progress(app, "ready", bytes.len() as u64, Some(bytes.len() as u64)); + Ok(bytes) + } + Err(error) => { + emit_update_progress(app, "idle", 0, None); + Err(error) + } + } } #[tauri::command] @@ -98,6 +195,12 @@ pub async fn check_for_update( let mut prepared = state.prepared.lock().await; if let Some(update) = prepared.as_ref() { + emit_update_progress( + &app, + "ready", + update.bytes.len() as u64, + Some(update.bytes.len() as u64), + ); return Ok(Some(update.meta.clone())); } @@ -105,13 +208,14 @@ pub async fn check_for_update( if !force { let last = parse_ts(read_str(&app, KEY_LAST_CHECK)); if !should_check(last, now, Duration::hours(THROTTLE_HOURS)) { + emit_update_progress(&app, "idle", 0, None); return Ok(None); } } write_str(&app, KEY_LAST_CHECK, &now.to_rfc3339()); - let updater = app.updater().map_err(|e| e.to_string())?; - let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + let Some(update) = find_update(&app).await? else { + emit_update_progress(&app, "idle", 0, None); return Ok(None); }; if !force @@ -120,11 +224,12 @@ pub async fn check_for_update( &update.version, ) { + emit_update_progress(&app, "idle", 0, None); return Ok(None); } let meta = update_meta(&update); - let bytes = download_update(&update).await?; + let bytes = download_update(&app, &update).await?; *prepared = Some(PreparedUpdate { meta: meta.clone(), update, @@ -193,13 +298,13 @@ pub async fn install_pending_update(app: AppHandle) -> Result<(), String> { let state = app.state::(); let mut prepared = state.prepared.lock().await; - let updater = app.updater().map_err(|e| e.to_string())?; - let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + let Some(update) = find_update(&app).await? else { + emit_update_progress(&app, "idle", 0, None); remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); return Ok(()); }; let meta = update_meta(&update); - let bytes = download_update(&update).await?; + let bytes = download_update(&app, &update).await?; *prepared = Some(PreparedUpdate { meta, update, @@ -232,3 +337,20 @@ pub fn should_check( pub fn is_skipped(skipped_version: Option<&str>, remote_version: &str) -> bool { skipped_version == Some(remote_version) } + +#[cfg(test)] +mod tests { + use super::progress_percent; + + #[test] + fn progress_requires_a_non_zero_total() { + assert_eq!(progress_percent(25, None), None); + assert_eq!(progress_percent(25, Some(0)), None); + } + + #[test] + fn progress_is_an_integer_percentage_capped_at_100() { + assert_eq!(progress_percent(25, Some(100)), Some(25)); + assert_eq!(progress_percent(200, Some(100)), Some(100)); + } +} diff --git a/crates/modbussim-app/src/update.rs b/crates/modbussim-app/src/update.rs index 8592b8b..960f4a6 100644 --- a/crates/modbussim-app/src/update.rs +++ b/crates/modbussim-app/src/update.rs @@ -1,15 +1,26 @@ use chrono::{DateTime, Duration, Utc}; use serde::Serialize; -use tauri::{AppHandle, Manager, State}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration as StdDuration; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_store::StoreExt; use tauri_plugin_updater::{Update, UpdaterExt}; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, time::timeout}; const STORE_FILE: &str = "update_state.json"; const KEY_LAST_CHECK: &str = "last_check_at"; const KEY_SKIPPED_VERSION: &str = "skipped_version"; const KEY_INSTALL_ON_NEXT_LAUNCH: &str = "install_on_next_launch"; const THROTTLE_HOURS: i64 = 6; +const CHECK_ENDPOINT_TIMEOUT: StdDuration = StdDuration::from_secs(10); +const CHECK_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30); +const DOWNLOAD_TIMEOUT: StdDuration = StdDuration::from_secs(5 * 60); +const PROGRESS_EVENT: &str = "update-progress"; +const CHECK_TIMEOUT_ERROR: &str = "UPDATE_CHECK_TIMEOUT"; +const DOWNLOAD_TIMEOUT_ERROR: &str = "UPDATE_DOWNLOAD_TIMEOUT"; #[derive(Serialize, Clone)] pub struct UpdateMeta { @@ -18,6 +29,14 @@ pub struct UpdateMeta { pub pub_date: Option, } +#[derive(Serialize, Clone)] +struct UpdateProgress { + stage: &'static str, + downloaded: u64, + total: Option, + percent: Option, +} + struct PreparedUpdate { meta: UpdateMeta, update: Update, @@ -75,14 +94,92 @@ fn update_meta(update: &Update) -> UpdateMeta { } } -async fn download_update(update: &Update) -> Result, String> { - update - .download( - |_, _| {}, - || log::info!("update download finished; verifying release signature"), - ) - .await - .map_err(|e| e.to_string()) +fn progress_percent(downloaded: u64, total: Option) -> Option { + total + .filter(|total| *total > 0) + .map(|total| ((downloaded.saturating_mul(100) / total).min(100)) as u8) +} + +fn emit_update_progress(app: &AppHandle, stage: &'static str, downloaded: u64, total: Option) { + let _ = app.emit( + PROGRESS_EVENT, + UpdateProgress { + stage, + downloaded, + total, + percent: progress_percent(downloaded, total), + }, + ); +} + +async fn find_update(app: &AppHandle) -> Result, String> { + emit_update_progress(app, "checking", 0, None); + let result = async { + let updater = app + .updater_builder() + .timeout(CHECK_ENDPOINT_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + timeout(CHECK_TOTAL_TIMEOUT, updater.check()) + .await + .map_err(|_| CHECK_TIMEOUT_ERROR.to_string())? + .map_err(|e| e.to_string()) + } + .await; + if result.is_err() { + emit_update_progress(app, "idle", 0, None); + } + result +} + +async fn download_update(app: &AppHandle, update: &Update) -> Result, String> { + emit_update_progress(app, "downloading", 0, None); + let progress_app = app.clone(); + let verify_app = app.clone(); + let downloaded = Arc::new(AtomicU64::new(0)); + let progress_downloaded = Arc::clone(&downloaded); + let verify_downloaded = Arc::clone(&downloaded); + let mut last_percent = None; + let mut last_emitted_bytes = 0_u64; + let download = update.download( + move |chunk_len, total| { + let downloaded = progress_downloaded + .fetch_add(chunk_len as u64, Ordering::Relaxed) + .saturating_add(chunk_len as u64); + let percent = progress_percent(downloaded, total); + let should_emit = percent != last_percent + || (total.is_none() && downloaded.saturating_sub(last_emitted_bytes) >= 256 * 1024); + if should_emit { + emit_update_progress(&progress_app, "downloading", downloaded, total); + last_percent = percent; + last_emitted_bytes = downloaded; + } + }, + move || { + emit_update_progress( + &verify_app, + "verifying", + verify_downloaded.load(Ordering::Relaxed), + None, + ); + log::info!("update download finished; verifying release signature"); + }, + ); + + let result = match timeout(DOWNLOAD_TIMEOUT, download).await { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err(DOWNLOAD_TIMEOUT_ERROR.to_string()), + }; + match result { + Ok(bytes) => { + emit_update_progress(app, "ready", bytes.len() as u64, Some(bytes.len() as u64)); + Ok(bytes) + } + Err(error) => { + emit_update_progress(app, "idle", 0, None); + Err(error) + } + } } #[tauri::command] @@ -98,6 +195,12 @@ pub async fn check_for_update( let mut prepared = state.prepared.lock().await; if let Some(update) = prepared.as_ref() { + emit_update_progress( + &app, + "ready", + update.bytes.len() as u64, + Some(update.bytes.len() as u64), + ); return Ok(Some(update.meta.clone())); } @@ -105,13 +208,14 @@ pub async fn check_for_update( if !force { let last = parse_ts(read_str(&app, KEY_LAST_CHECK)); if !should_check(last, now, Duration::hours(THROTTLE_HOURS)) { + emit_update_progress(&app, "idle", 0, None); return Ok(None); } } write_str(&app, KEY_LAST_CHECK, &now.to_rfc3339()); - let updater = app.updater().map_err(|e| e.to_string())?; - let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + let Some(update) = find_update(&app).await? else { + emit_update_progress(&app, "idle", 0, None); return Ok(None); }; if !force @@ -120,11 +224,12 @@ pub async fn check_for_update( &update.version, ) { + emit_update_progress(&app, "idle", 0, None); return Ok(None); } let meta = update_meta(&update); - let bytes = download_update(&update).await?; + let bytes = download_update(&app, &update).await?; *prepared = Some(PreparedUpdate { meta: meta.clone(), update, @@ -193,13 +298,13 @@ pub async fn install_pending_update(app: AppHandle) -> Result<(), String> { let state = app.state::(); let mut prepared = state.prepared.lock().await; - let updater = app.updater().map_err(|e| e.to_string())?; - let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + let Some(update) = find_update(&app).await? else { + emit_update_progress(&app, "idle", 0, None); remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); return Ok(()); }; let meta = update_meta(&update); - let bytes = download_update(&update).await?; + let bytes = download_update(&app, &update).await?; *prepared = Some(PreparedUpdate { meta, update, @@ -232,3 +337,20 @@ pub fn should_check( pub fn is_skipped(skipped_version: Option<&str>, remote_version: &str) -> bool { skipped_version == Some(remote_version) } + +#[cfg(test)] +mod tests { + use super::progress_percent; + + #[test] + fn progress_requires_a_non_zero_total() { + assert_eq!(progress_percent(25, None), None); + assert_eq!(progress_percent(25, Some(0)), None); + } + + #[test] + fn progress_is_an_integer_percentage_capped_at_100() { + assert_eq!(progress_percent(25, Some(100)), Some(25)); + assert_eq!(progress_percent(200, Some(100)), Some(100)); + } +} diff --git a/frontend/src/components/Toolbar.vue b/frontend/src/components/Toolbar.vue index c42bdfb..5099bca 100644 --- a/frontend/src/components/Toolbar.vue +++ b/frontend/src/components/Toolbar.vue @@ -1,8 +1,16 @@