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}