fix(updater): retry the check and download, report progress
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-windows (push) Canceled after 4m13s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-windows (push) Canceled after 4m13s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
A friend's in-app update failed ten times ("error sending request for
url (…nsis.zip)") against a busy Gitea before the 11th worked.
- check_for_update / install_update retry transport errors (Reqwest,
Network, ReleaseNotFound) with 3 s / 8 s / 15 s backoff — four tries
per click. Signature or install errors fail at once, never masked.
- Every request gets a 600 s timeout; reqwest has none by default, so a
stalled connection could leave the UI on "installing" forever.
- Download and install are separate steps so a retry never re-installs.
- `lotus-update-progress` events (downloading %, retrying + wait,
installing) drive the new progress text in Settings.
- Errors are prefixed `check:` / `download:` / `install:` so the UI says
which step failed (it used to call a failed download "Update check
failed").
- Adds tokio (time feature only; already in the tree via tauri).
- Bump cinny to 568f218f (the matching UI).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5a9bfa8fe4
commit
8f98a82562
+1
-1
Submodule cinny updated: 23649f1255...568f218fe9
Generated
+1
@@ -510,6 +510,7 @@ dependencies = [
|
|||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
|
"tokio",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ custom-protocol = [ "tauri/custom-protocol" ]
|
|||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
tauri-plugin-autostart = "2" # P6-1 launch-on-login
|
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]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
|
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
|
||||||
|
|||||||
+126
-24
@@ -130,46 +130,148 @@ struct UpdateInfo {
|
|||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<tauri_plugin_updater::Updater, String> {
|
||||||
|
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<Option<tauri_plugin_updater::Update>, 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]
|
#[tauri::command]
|
||||||
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
return match update_retry::check(&app).await? {
|
||||||
return match app.updater().map_err(|e| e.to_string())?.check().await {
|
Some(update) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
|
||||||
Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
|
None => Ok(UpdateInfo { available: false, version: None }),
|
||||||
Ok(None) => Ok(UpdateInfo { available: false, version: None }),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||||
Ok(UpdateInfo { available: false, version: None })
|
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]
|
#[tauri::command]
|
||||||
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use std::time::{Duration, Instant};
|
||||||
if let Some(update) = app
|
|
||||||
.updater()
|
let emit = |detail: serde_json::Value| {
|
||||||
.map_err(|e| e.to_string())?
|
native::emit_to_web(&app, "lotus-update-progress", &detail.to_string());
|
||||||
.check()
|
};
|
||||||
|
|
||||||
|
let Some(update) = update_retry::check(&app)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| format!("check: {e}"))?
|
||||||
{
|
else {
|
||||||
update
|
return Ok(());
|
||||||
.download_and_install(|_chunk, _total| {}, || {})
|
};
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
let max_attempts = update_retry::BACKOFF.len() + 1;
|
||||||
// Only reached on a successful download+install (the `?` above bails
|
let mut attempt = 0;
|
||||||
// otherwise). Relaunch so the freshly installed version actually
|
let bytes = loop {
|
||||||
// runs — without this the UI hangs on "installing", and on a Linux
|
emit(serde_json::json!({
|
||||||
// AppImage the running process is still the old binary. `restart()`
|
"phase": "downloading", "attempt": attempt + 1, "maxAttempts": max_attempts,
|
||||||
// exits the current process and never returns, so nothing after it
|
"downloaded": 0, "total": null,
|
||||||
// runs for the update case.
|
}));
|
||||||
app.restart();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user