Skip to main content

brows3r_lib/updater/
mod.rs

1//! Auto-updater logic using `tauri-plugin-updater`.
2//!
3//! # Responsibilities
4//!
5//! - `UpdateStatus` — serializable state machine shared with the frontend.
6//! - `check_for_update` — asks the updater endpoint whether a newer version is
7//!   available.
8//! - `install_update` — downloads and stages the pending update so Tauri can
9//!   restart into it.
10//!
11//! # OCP
12//!
13//! `UpdateStatus` is open for new variants.  The frontend discriminates on the
14//! `status` field (a `type` tag), so adding a variant here only requires a new
15//! branch in the frontend switch.  Existing arms are unaffected.
16
17use serde::Serialize;
18use tauri::AppHandle;
19use tauri_plugin_updater::UpdaterExt;
20
21use crate::error::AppError;
22
23// ---------------------------------------------------------------------------
24// UpdateStatus
25// ---------------------------------------------------------------------------
26
27/// Every state the updater can be in.
28///
29/// Serialises as `{ "status": "<variant>", ...fields }` via `#[serde(tag)]`.
30///
31/// # OCP note
32/// New variants extend the enum without modifying existing arms.
33#[derive(Debug, Clone, Serialize)]
34#[serde(tag = "status", rename_all = "camelCase")]
35#[serde(rename_all_fields = "camelCase")]
36pub enum UpdateStatus {
37    /// No check in progress; nothing has happened yet.
38    Idle,
39    /// A version-check request is in flight.
40    Checking,
41    /// A newer version is available and has not yet been downloaded.
42    Available {
43        version: String,
44        notes: Option<String>,
45        download_url: Option<String>,
46    },
47    /// The update binary is being downloaded.
48    Downloading {
49        /// Progress fraction in `[0.0, 1.0]`.  `None` when total size is unknown.
50        progress: Option<f32>,
51    },
52    /// Download complete; the app is ready to restart into the new version.
53    Ready,
54    /// Already on the latest version.
55    UpToDate,
56    /// Something went wrong.
57    Error { message: String },
58}
59
60// ---------------------------------------------------------------------------
61// check_for_update
62// ---------------------------------------------------------------------------
63
64/// Check the configured updater endpoint for a newer version.
65///
66/// Returns `UpdateStatus::Available { … }` when a newer release is published,
67/// or `UpdateStatus::UpToDate` when the current version is current.
68///
69/// The caller is responsible for emitting `updater:status` events so the
70/// frontend can track transitions.
71pub async fn check_for_update(app: &AppHandle) -> Result<UpdateStatus, AppError> {
72    let updater = app.updater().map_err(|e| AppError::Internal {
73        trace_id: format!("updater_init: {e}"),
74    })?;
75
76    match updater.check().await {
77        Ok(Some(update)) => {
78            let version = update.version.clone();
79            let notes = update.body.clone();
80            // The download URL is not directly exposed by the plugin's public
81            // API — the update handle carries it internally.  We surface
82            // `None` here; the frontend only needs it for display purposes and
83            // the actual download is triggered through `install_update`.
84            Ok(UpdateStatus::Available {
85                version,
86                notes,
87                download_url: None,
88            })
89        }
90        Ok(None) => Ok(UpdateStatus::UpToDate),
91        Err(e) => Ok(UpdateStatus::Error {
92            message: e.to_string(),
93        }),
94    }
95}
96
97// ---------------------------------------------------------------------------
98// install_update
99// ---------------------------------------------------------------------------
100
101/// Download and stage the pending update, emitting `Downloading { progress }`
102/// events along the way so the UI's progress bar can advance.
103///
104/// Must only be called after `check_for_update` has returned
105/// `UpdateStatus::Available`.  Calling this when no update is pending returns
106/// `AppError::Validation`.
107pub async fn install_update(app: &AppHandle) -> Result<(), AppError> {
108    let updater = app.updater().map_err(|e| AppError::Internal {
109        trace_id: format!("updater_init: {e}"),
110    })?;
111
112    let update = updater
113        .check()
114        .await
115        .map_err(|e| AppError::Network {
116            source: format!("updater check: {e}"),
117        })?
118        .ok_or_else(|| AppError::Validation {
119            field: "update".to_string(),
120            hint: "No pending update available".to_string(),
121        })?;
122
123    // tauri-plugin-updater hands us byte counters on every chunk and a
124    // total size at the start. Translate those into the
125    // `UpdateStatus::Downloading { progress }` events the UI subscribes
126    // to. Without this, `UpdaterPrompt` shows a 40%-width indeterminate
127    // bar that never moves.
128    use std::cell::Cell;
129    let downloaded = Cell::new(0u64);
130    let total = Cell::new(None::<u64>);
131    let app_for_progress = app.clone();
132
133    update
134        .download_and_install(
135            move |chunk_len, content_length| {
136                if total.get().is_none() {
137                    total.set(content_length);
138                }
139                let acc = downloaded.get() + chunk_len as u64;
140                downloaded.set(acc);
141                let progress = total.get().and_then(|t| {
142                    if t == 0 {
143                        None
144                    } else {
145                        // Clamp to [0,1] in case the plugin briefly
146                        // reports more bytes than the announced total.
147                        let pct = (acc as f32 / t as f32).clamp(0.0, 1.0);
148                        Some(pct)
149                    }
150                });
151                let _ = crate::events::emit(
152                    &app_for_progress,
153                    crate::events::EventKind::UpdaterStatus,
154                    &UpdateStatus::Downloading { progress },
155                );
156            },
157            || {},
158        )
159        .await
160        .map_err(|e| AppError::Network {
161            source: format!("updater install: {e}"),
162        })?;
163
164    Ok(())
165}
166
167// ---------------------------------------------------------------------------
168// restart
169// ---------------------------------------------------------------------------
170
171/// Restart the application process. Used by `UpdaterPrompt` after the
172/// `Ready` state — `window.location.reload()` only reloaded the WebView
173/// in the SAME binary so the freshly-installed update never took
174/// effect. `AppHandle::restart()` exits and re-execs the staged binary.
175pub fn restart(app: &AppHandle) {
176    // `AppHandle::restart()` is `-> !` and terminates the process.
177    app.restart();
178}
179
180// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use serde_json::Value;
188
189    fn ser(s: &UpdateStatus) -> Value {
190        serde_json::to_value(s).expect("UpdateStatus must serialize")
191    }
192
193    #[test]
194    fn idle_serializes() {
195        let v = ser(&UpdateStatus::Idle);
196        assert_eq!(v["status"], "idle");
197    }
198
199    #[test]
200    fn checking_serializes() {
201        let v = ser(&UpdateStatus::Checking);
202        assert_eq!(v["status"], "checking");
203    }
204
205    #[test]
206    fn available_serializes() {
207        let v = ser(&UpdateStatus::Available {
208            version: "1.2.3".to_string(),
209            notes: Some("Bug fixes".to_string()),
210            download_url: Some("https://example.com/release".to_string()),
211        });
212        assert_eq!(v["status"], "available");
213        assert_eq!(v["version"], "1.2.3");
214        assert_eq!(v["notes"], "Bug fixes");
215        assert_eq!(v["downloadUrl"], "https://example.com/release");
216    }
217
218    #[test]
219    fn available_optional_fields_null() {
220        let v = ser(&UpdateStatus::Available {
221            version: "0.2.0".to_string(),
222            notes: None,
223            download_url: None,
224        });
225        assert_eq!(v["status"], "available");
226        assert_eq!(v["version"], "0.2.0");
227        assert!(v["notes"].is_null());
228        assert!(v["downloadUrl"].is_null());
229    }
230
231    #[test]
232    fn downloading_with_progress_serializes() {
233        let v = ser(&UpdateStatus::Downloading {
234            progress: Some(0.42),
235        });
236        assert_eq!(v["status"], "downloading");
237        let p = v["progress"].as_f64().expect("progress must be f64");
238        assert!((p - 0.42_f64).abs() < 0.001);
239    }
240
241    #[test]
242    fn downloading_unknown_progress_serializes() {
243        let v = ser(&UpdateStatus::Downloading { progress: None });
244        assert_eq!(v["status"], "downloading");
245        assert!(v["progress"].is_null());
246    }
247
248    #[test]
249    fn ready_serializes() {
250        let v = ser(&UpdateStatus::Ready);
251        assert_eq!(v["status"], "ready");
252    }
253
254    #[test]
255    fn up_to_date_serializes() {
256        let v = ser(&UpdateStatus::UpToDate);
257        assert_eq!(v["status"], "upToDate");
258    }
259
260    #[test]
261    fn error_serializes() {
262        let v = ser(&UpdateStatus::Error {
263            message: "network timeout".to_string(),
264        });
265        assert_eq!(v["status"], "error");
266        assert_eq!(v["message"], "network timeout");
267    }
268}