Skip to main content

entracte_lib/
updater.rs

1use crate::scheduler::UpdateChannel;
2use serde::Serialize;
3use tauri::AppHandle;
4use tauri_plugin_updater::UpdaterExt;
5
6/// Signed manifest for the stable line. GitHub's `releases/latest`
7/// resolves to the newest **non-prerelease** release, which is what keeps
8/// betas out of it.
9pub const STABLE_ENDPOINT: &str =
10    "https://github.com/drmowinckels/entracte/releases/latest/download/latest.json";
11
12/// Signed manifest for the beta line, published to a rolling
13/// `channel-beta` release. It cannot use `releases/latest`: beta releases
14/// are marked prerelease (so they never hijack the stable pointer), and
15/// `releases/latest` skips prereleases — pointing the beta channel there
16/// would resolve to the stable manifest, which is how #238 broke the
17/// update check when every release was a prerelease.
18pub const BETA_ENDPOINT: &str =
19    "https://github.com/drmowinckels/entracte/releases/download/channel-beta/latest.json";
20
21/// The channel's manifest URL, parsed into a `Url`.
22///
23/// Split out from [`check_channel`] so the parse and its error mapping
24/// are unit-testable; what remains in `check_channel` is live-runtime
25/// glue (`updater_builder`, a network fetch) with no mockable surface.
26pub 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
32/// The manifest URL a channel reads from.
33///
34/// This is the *entire* mechanism separating the two lines. The version
35/// comparator cannot assist: semver ranks a prerelease above the stable
36/// release it precedes (`0.1.1-beta.1 > 0.1.0`), so a beta manifest
37/// reaching a stable install would be offered and installed. Keeping
38/// betas out of `releases/latest` — by marking those releases prerelease
39/// in `release.yml` — is therefore load-bearing, not cosmetic.
40pub fn channel_endpoint(channel: UpdateChannel) -> &'static str {
41    match channel {
42        UpdateChannel::Stable => STABLE_ENDPOINT,
43        UpdateChannel::Beta => BETA_ENDPOINT,
44    }
45}
46
47/// Result of checking the updater endpoint for a newer Entracte build.
48///
49/// `has_update` is true when the signed `latest.json` manifest at the
50/// configured endpoint advertises a strictly greater version than the
51/// running build (the plugin's default SemVer comparator). `release_url`
52/// is populated only when an update is available; the renderer
53/// deep-links to the release page from the About tab in that case.
54#[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/// Subset of `tauri_plugin_updater::Update` that the result-mapping
63/// logic actually consumes. Extracted so the mapping can be unit-tested
64/// without a live Tauri runtime.
65#[derive(Debug, Clone)]
66pub struct UpdatePayload {
67    pub version: String,
68    pub current_version: String,
69}
70
71/// Pure mapping from "running version + optional plugin result" to the
72/// renderer-facing `UpdateInfo`. Hardcoded `v` prefix on the release
73/// URL matches the release tagging convention (`v0.0.1`, `v0.1.0`, …)
74/// documented in CONTRIBUTING.md. Untag-prefixed releases would break
75/// the deep-link — change here and in the workflow together if the
76/// convention ever shifts.
77pub 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/// Ask `tauri-plugin-updater` whether a newer build is available.
98///
99/// Delegates to `app.updater()?.check()`, which fetches the signed
100/// manifest from `plugins.updater.endpoints` (configured in
101/// `tauri.conf.json`), verifies its signature against the bundled
102/// `plugins.updater.pubkey`, and compares versions with the plugin's
103/// SemVer default. Errors stringify the underlying plugin / transport
104/// failure for display in the About tab.
105#[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
113/// The channel the running profile is set to.
114///
115/// Extracted from [`check_for_update`] so that "the check follows the
116/// saved channel, not a hardcoded one" is an actually tested claim —
117/// the command itself cannot be unit-tested, because `updater_builder`
118/// panics without the updater plugin and a real check would hit the
119/// network.
120pub async fn active_channel(scheduler: &crate::scheduler::Scheduler) -> UpdateChannel {
121    scheduler.settings.lock().await.update_channel
122}
123
124/// Channel-aware check, split out so the startup path can reuse it
125/// without going through the IPC layer.
126pub 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
143/// Title + body for the "update available" desktop notification, or `None`
144/// when there's nothing to announce (already on the latest build). Pure so the
145/// copy and the no-update guard are unit-tested without a live updater or the
146/// notification plugin.
147pub 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/// Opt-in startup auto-check (the `auto_check_updates` setting). Checks the
161/// updater endpoint once in the background and posts a non-intrusive desktop
162/// notification if a newer build is available. Silent on no-update and on any
163/// error — being offline must never nag — and never blocks startup. Split on
164/// `cfg(test)` (mirrors `overlay::spawn_render_watchdog`) so the test/coverage
165/// build neither spawns a task nor fires a real OS notification; the decision
166/// logic lives in the pure, tested `update_notification`.
167#[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    /// The shipped version lives in five files. A drift between them
194    /// surfaces as a phantom "update available": the updater compares the
195    /// running build's version against the latest GitHub tag, so a build
196    /// that reports an older number than it was released as will offer
197    /// itself an update forever.
198    ///
199    /// This exists because `docs/developer/releases.md` documented only
200    /// two of the five, so following it would have left `Cargo.toml`,
201    /// `Cargo.lock` and `package-lock.json` behind. `Cargo.lock` is not
202    /// checked here — cargo rewrites it from `Cargo.toml` on the next
203    /// build, so it cannot drift independently.
204    #[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        // This is the invariant that keeps betas off stable installs, and
284        // it cannot be delegated to the version comparator: semver ranks
285        // `0.1.1-beta.1` ABOVE `0.1.0`, so a beta manifest read by a
286        // stable install would be offered and installed. Separation is
287        // purely a function of which URL each channel reads.
288        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        // The beta manifest lives on a fixed, rolling tag rather than a
302        // versioned one, so the URL compiled into the binary keeps
303        // resolving as new betas ship.
304        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        // `check_channel` parses these into `Url`; a typo here would fail
311        // at runtime on every check rather than at compile time.
312        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        // The running version is used in both `current` and `latest`
400        // even if the caller previously held a stale UpdatePayload —
401        // None is the single source of truth for "no update".
402        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        // The plugin reports its own view of the running version in
411        // `current_version`; we trust that over our `running_version`
412        // arg when an update is reported, so a mismatch surfaces the
413        // plugin's value (debug visibility into version skew).
414        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}