1use crate::scheduler::UpdateChannel;
2use serde::Serialize;
3use tauri::AppHandle;
4use tauri_plugin_updater::UpdaterExt;
5
6pub const STABLE_ENDPOINT: &str =
10 "https://github.com/drmowinckels/entracte/releases/latest/download/latest.json";
11
12pub const BETA_ENDPOINT: &str =
19 "https://github.com/drmowinckels/entracte/releases/download/channel-beta/latest.json";
20
21pub fn channel_endpoint_url(channel: UpdateChannel) -> Result<tauri::Url, String> {
27 channel_endpoint(channel)
28 .parse()
29 .map_err(|e| format!("invalid updater endpoint for {channel:?}: {e}"))
30}
31
32pub fn channel_endpoint(channel: UpdateChannel) -> &'static str {
41 match channel {
42 UpdateChannel::Stable => STABLE_ENDPOINT,
43 UpdateChannel::Beta => BETA_ENDPOINT,
44 }
45}
46
47#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
55pub struct UpdateInfo {
56 pub current: String,
57 pub latest: String,
58 pub has_update: bool,
59 pub release_url: Option<String>,
60}
61
62#[derive(Debug, Clone)]
66pub struct UpdatePayload {
67 pub version: String,
68 pub current_version: String,
69}
70
71pub fn build_update_info(running_version: String, update: Option<UpdatePayload>) -> UpdateInfo {
78 match update {
79 Some(u) => UpdateInfo {
80 has_update: true,
81 release_url: Some(format!(
82 "https://github.com/drmowinckels/entracte/releases/tag/v{}",
83 u.version
84 )),
85 current: u.current_version,
86 latest: u.version,
87 },
88 None => UpdateInfo {
89 has_update: false,
90 release_url: None,
91 current: running_version.clone(),
92 latest: running_version,
93 },
94 }
95}
96
97#[tauri::command]
106pub async fn check_for_update(
107 app: AppHandle,
108 scheduler: tauri::State<'_, crate::scheduler::Scheduler>,
109) -> Result<UpdateInfo, String> {
110 check_channel(app, active_channel(&scheduler).await).await
111}
112
113pub async fn active_channel(scheduler: &crate::scheduler::Scheduler) -> UpdateChannel {
121 scheduler.settings.lock().await.update_channel
122}
123
124pub async fn check_channel(app: AppHandle, channel: UpdateChannel) -> Result<UpdateInfo, String> {
127 let current = app.package_info().version.to_string();
128 let endpoints = vec![channel_endpoint_url(channel)?];
129 let builder = app.updater_builder().endpoints(endpoints);
130 let updater = builder.map_err(|e| e.to_string())?.build();
131 let updater = updater.map_err(|e| e.to_string())?;
132 let payload = updater
133 .check()
134 .await
135 .map_err(|e| e.to_string())?
136 .map(|u| UpdatePayload {
137 version: u.version.clone(),
138 current_version: u.current_version.clone(),
139 });
140 Ok(build_update_info(current, payload))
141}
142
143pub fn update_notification(info: &UpdateInfo) -> Option<(String, String)> {
148 if !info.has_update {
149 return None;
150 }
151 Some((
152 "Update available".to_string(),
153 format!(
154 "Entracte {} is available (you have {}). Open Preferences \u{2192} About to update.",
155 info.latest, info.current
156 ),
157 ))
158}
159
160#[cfg(not(test))]
168pub fn spawn_startup_check(app: AppHandle, settings: &crate::scheduler::Settings) {
169 if !settings.auto_check_updates {
170 return;
171 }
172 let channel = settings.update_channel;
173 tauri::async_runtime::spawn(async move {
174 match check_channel(app.clone(), channel).await {
175 Ok(info) => {
176 if let Some((title, body)) = update_notification(&info) {
177 use tauri_plugin_notification::NotificationExt;
178 let _ = app.notification().builder().title(title).body(body).show();
179 }
180 }
181 Err(e) => log::debug!("updater: startup check skipped: {e}"),
182 }
183 });
184}
185
186#[cfg(test)]
187pub fn spawn_startup_check(_app: AppHandle, _settings: &crate::scheduler::Settings) {}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
205 fn shipped_version_agrees_across_every_manifest() {
206 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
207 let want = env!("CARGO_PKG_VERSION");
208
209 let read = |rel: &str| -> serde_json::Value {
210 let path = root.join(rel);
211 let raw = std::fs::read_to_string(&path)
212 .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
213 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parsing {}: {e}", path.display()))
214 };
215
216 let tauri_conf = read("tauri.conf.json");
217 assert_eq!(
218 tauri_conf["version"].as_str(),
219 Some(want),
220 "tauri.conf.json drifted from Cargo.toml ({want})"
221 );
222
223 let pkg = read("../package.json");
224 assert_eq!(
225 pkg["version"].as_str(),
226 Some(want),
227 "package.json drifted from Cargo.toml ({want})"
228 );
229
230 let lock = read("../package-lock.json");
231 assert_eq!(
232 lock["version"].as_str(),
233 Some(want),
234 "package-lock.json root version drifted from Cargo.toml ({want})"
235 );
236 assert_eq!(
237 lock["packages"][""]["version"].as_str(),
238 Some(want),
239 "package-lock.json packages[\"\"] version drifted from Cargo.toml ({want})"
240 );
241 }
242
243 #[tokio::test]
244 async fn active_channel_reads_the_saved_setting() {
245 use crate::scheduler::UpdateChannel;
246 for want in [UpdateChannel::Stable, UpdateChannel::Beta] {
247 let settings = crate::scheduler::Settings {
248 update_channel: want,
249 ..Default::default()
250 };
251 let (_dir, sched) = crate::test_support::test_scheduler(settings);
252 assert_eq!(
253 active_channel(&sched).await,
254 want,
255 "the update check must follow the saved channel"
256 );
257 }
258 }
259
260 #[test]
261 fn channel_endpoint_url_parses_for_every_channel() {
262 for channel in [UpdateChannel::Stable, UpdateChannel::Beta] {
263 let url = channel_endpoint_url(channel)
264 .unwrap_or_else(|e| panic!("{channel:?} endpoint should parse: {e}"));
265 assert_eq!(url.scheme(), "https", "{channel:?} must be https");
266 assert_eq!(url.as_str(), channel_endpoint(channel));
267 }
268 }
269
270 #[test]
271 fn channel_endpoint_maps_each_channel_to_its_own_manifest() {
272 assert_eq!(channel_endpoint(UpdateChannel::Stable), STABLE_ENDPOINT);
273 assert_eq!(channel_endpoint(UpdateChannel::Beta), BETA_ENDPOINT);
274 assert_ne!(
275 channel_endpoint(UpdateChannel::Stable),
276 channel_endpoint(UpdateChannel::Beta),
277 "the two channels must not share a manifest"
278 );
279 }
280
281 #[test]
282 fn only_the_stable_channel_uses_the_releases_latest_pointer() {
283 assert!(
289 channel_endpoint(UpdateChannel::Stable).contains("/releases/latest/"),
290 "stable reads GitHub's latest pointer, which skips prereleases"
291 );
292 assert!(
293 !channel_endpoint(UpdateChannel::Beta).contains("/releases/latest/"),
294 "beta must NOT read the latest pointer — it skips prereleases, so \
295 the beta channel would silently resolve to the stable manifest (#238)"
296 );
297 }
298
299 #[test]
300 fn beta_endpoint_targets_the_rolling_channel_release() {
301 assert!(channel_endpoint(UpdateChannel::Beta).contains("/releases/download/channel-beta/"));
305 assert!(channel_endpoint(UpdateChannel::Beta).ends_with("/latest.json"));
306 }
307
308 #[test]
309 fn both_endpoints_are_parseable_urls() {
310 for channel in [UpdateChannel::Stable, UpdateChannel::Beta] {
313 let raw = channel_endpoint(channel);
314 assert!(
315 raw.parse::<tauri::Url>().is_ok(),
316 "{channel:?} endpoint is not a valid URL: {raw}"
317 );
318 assert!(
319 raw.starts_with("https://"),
320 "{channel:?} endpoint must be https"
321 );
322 }
323 }
324
325 #[test]
326 fn notification_announces_an_available_update_with_both_versions() {
327 let info = build_update_info(
328 "0.0.8".to_string(),
329 Some(UpdatePayload {
330 version: "0.0.9".to_string(),
331 current_version: "0.0.8".to_string(),
332 }),
333 );
334 let (title, body) = update_notification(&info).expect("an update should be announced");
335 assert!(title.contains("Update"));
336 assert!(body.contains("0.0.9"), "body names the new version: {body}");
337 assert!(
338 body.contains("0.0.8"),
339 "body names the current version: {body}"
340 );
341 }
342
343 #[test]
344 fn no_notification_when_already_on_the_latest_build() {
345 let info = build_update_info("0.0.8".to_string(), None);
346 assert!(update_notification(&info).is_none());
347 }
348
349 #[test]
350 fn no_update_clones_running_version_into_both_current_and_latest() {
351 let info = build_update_info("0.0.1".to_string(), None);
352 assert_eq!(
353 info,
354 UpdateInfo {
355 current: "0.0.1".to_string(),
356 latest: "0.0.1".to_string(),
357 has_update: false,
358 release_url: None,
359 }
360 );
361 }
362
363 #[test]
364 fn update_available_yields_v_prefixed_release_url() {
365 let info = build_update_info(
366 "0.0.1".to_string(),
367 Some(UpdatePayload {
368 version: "0.0.2".to_string(),
369 current_version: "0.0.1".to_string(),
370 }),
371 );
372 assert!(info.has_update);
373 assert_eq!(info.current, "0.0.1");
374 assert_eq!(info.latest, "0.0.2");
375 assert_eq!(
376 info.release_url.as_deref(),
377 Some("https://github.com/drmowinckels/entracte/releases/tag/v0.0.2"),
378 );
379 }
380
381 #[test]
382 fn update_with_pre_release_tag_keeps_full_version_in_url() {
383 let info = build_update_info(
384 "0.0.1".to_string(),
385 Some(UpdatePayload {
386 version: "0.1.0-rc1".to_string(),
387 current_version: "0.0.1".to_string(),
388 }),
389 );
390 assert_eq!(
391 info.release_url.as_deref(),
392 Some("https://github.com/drmowinckels/entracte/releases/tag/v0.1.0-rc1"),
393 );
394 assert_eq!(info.latest, "0.1.0-rc1");
395 }
396
397 #[test]
398 fn no_update_ignores_passed_payload_when_none() {
399 let info = build_update_info("1.2.3".to_string(), None);
403 assert_eq!(info.current, "1.2.3");
404 assert_eq!(info.latest, "1.2.3");
405 assert!(info.release_url.is_none());
406 }
407
408 #[test]
409 fn update_available_takes_current_version_from_plugin_not_running_arg() {
410 let info = build_update_info(
415 "0.0.1-local".to_string(),
416 Some(UpdatePayload {
417 version: "0.0.2".to_string(),
418 current_version: "0.0.1".to_string(),
419 }),
420 );
421 assert_eq!(info.current, "0.0.1");
422 }
423}