diff --git a/cinny b/cinny index 23649f1..568f218 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 23649f1255d23e29ded9b485097a3adc601da93f +Subproject commit 568f218fe936f619dc8dd902558ac61016243170 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8861846..f7caaf2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -510,6 +510,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tokio", "webkit2gtk", "webview2-com", "window-vibrancy", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index efb1944..34d18cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,8 @@ custom-protocol = [ "tauri/custom-protocol" ] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" # P6-1 launch-on-login +# Update retry backoff (already in the tree via tauri; adds only the timer). +tokio = { version = "1", features = ["time"] } [target.'cfg(target_os = "linux")'.dependencies] # P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f172c9..841191a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,46 +130,148 @@ struct UpdateInfo { version: Option, } +/// Update-flow resilience (Cole, 2026-09-23: ten failed "error sending request" +/// attempts against a busy Gitea before the 11th went through). Network-level +/// failures are retried with backoff inside one click; anything else (bad +/// signature, broken package) fails at once so it is never masked. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod update_retry { + use std::time::Duration; + + /// Delays before attempts 2, 3, 4 (so four tries in all). + pub const BACKOFF: [Duration; 3] = [ + Duration::from_secs(3), + Duration::from_secs(8), + Duration::from_secs(15), + ]; + + /// Per-request cap. reqwest has no default timeout, so a stalled connection + /// could otherwise hang the "installing" state forever. Generous enough for + /// the ~50 MB installer on a slow line. + pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(600); + + /// Only transport errors are worth retrying. + pub fn is_transient(err: &tauri_plugin_updater::Error) -> bool { + use tauri_plugin_updater::Error; + matches!( + err, + Error::Reqwest(_) | Error::Network(_) | Error::ReleaseNotFound + ) + } + + pub fn updater( + app: &tauri::AppHandle, + ) -> Result { + use tauri_plugin_updater::UpdaterExt; + app.updater_builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| e.to_string()) + } + + /// `check()` with retries. `Ok(None)` = up to date. + pub async fn check( + app: &tauri::AppHandle, + ) -> Result, String> { + let updater = updater(app)?; + let mut attempt = 0; + loop { + match updater.check().await { + Ok(found) => return Ok(found), + Err(e) if is_transient(&e) && attempt < BACKOFF.len() => { + tokio::time::sleep(BACKOFF[attempt]).await; + attempt += 1; + } + Err(e) => return Err(e.to_string()), + } + } + } +} + #[tauri::command] async fn check_for_update(app: tauri::AppHandle) -> Result { #[cfg(not(any(target_os = "android", target_os = "ios")))] { - use tauri_plugin_updater::UpdaterExt; - return match app.updater().map_err(|e| e.to_string())?.check().await { - Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }), - Ok(None) => Ok(UpdateInfo { available: false, version: None }), - Err(e) => Err(e.to_string()), + return match update_retry::check(&app).await? { + Some(update) => Ok(UpdateInfo { available: true, version: Some(update.version) }), + None => Ok(UpdateInfo { available: false, version: None }), }; } #[cfg(any(target_os = "android", target_os = "ios"))] Ok(UpdateInfo { available: false, version: None }) } +/// Download (with retries and `lotus-update-progress` events for the web UI), +/// then install and relaunch. Errors are prefixed `check:` / `download:` / +/// `install:` so the web side can say which step failed. #[tauri::command] async fn install_update(app: tauri::AppHandle) -> Result<(), String> { #[cfg(not(any(target_os = "android", target_os = "ios")))] { - use tauri_plugin_updater::UpdaterExt; - if let Some(update) = app - .updater() - .map_err(|e| e.to_string())? - .check() + use std::time::{Duration, Instant}; + + let emit = |detail: serde_json::Value| { + native::emit_to_web(&app, "lotus-update-progress", &detail.to_string()); + }; + + let Some(update) = update_retry::check(&app) .await - .map_err(|e| e.to_string())? - { - update - .download_and_install(|_chunk, _total| {}, || {}) - .await - .map_err(|e| e.to_string())?; - // Only reached on a successful download+install (the `?` above bails - // otherwise). Relaunch so the freshly installed version actually - // runs — without this the UI hangs on "installing", and on a Linux - // AppImage the running process is still the old binary. `restart()` - // exits the current process and never returns, so nothing after it - // runs for the update case. - app.restart(); - } + .map_err(|e| format!("check: {e}"))? + else { + return Ok(()); + }; + + let max_attempts = update_retry::BACKOFF.len() + 1; + let mut attempt = 0; + let bytes = loop { + emit(serde_json::json!({ + "phase": "downloading", "attempt": attempt + 1, "maxAttempts": max_attempts, + "downloaded": 0, "total": null, + })); + let mut downloaded: u64 = 0; + let mut last_emit = Instant::now(); + let result = update + .download( + |chunk, total| { + downloaded += chunk as u64; + // ~4 Hz is plenty for a progress line. + if last_emit.elapsed() >= Duration::from_millis(250) { + last_emit = Instant::now(); + emit(serde_json::json!({ + "phase": "downloading", "attempt": attempt + 1, + "maxAttempts": max_attempts, + "downloaded": downloaded, "total": total, + })); + } + }, + || {}, + ) + .await; + match result { + Ok(bytes) => break bytes, + Err(e) if update_retry::is_transient(&e) && attempt < update_retry::BACKOFF.len() => { + let wait = update_retry::BACKOFF[attempt]; + emit(serde_json::json!({ + "phase": "retrying", "attempt": attempt + 1, "maxAttempts": max_attempts, + "waitSecs": wait.as_secs(), "error": e.to_string(), + })); + tokio::time::sleep(wait).await; + attempt += 1; + } + Err(e) => return Err(format!("download: {e}")), + } + }; + + emit(serde_json::json!({ "phase": "installing" })); + update.install(bytes).map_err(|e| format!("install: {e}"))?; + // Only reached on a successful install. Relaunch so the freshly + // installed version actually runs — without this the UI hangs on + // "installing", and on a Linux AppImage the running process is still the + // old binary. On Windows the plugin has already exited the process to + // run the installer, so this is not reached there. + app.restart(); } + #[allow(unreachable_code)] Ok(()) }