Skip to main content

tauri_plugin_hot_update/
commands.rs

1//! The IPC command surface — thin wrappers over [`HotUpdate`] whose JSON
2//! shapes are the cross-language contract with the TypeScript package
3//! (`guest-js/`, npm `tauri-plugin-hot-update-api`). Every serialized shape
4//! here is pinned by a golden test in `commands/tests.rs`; a Rust-side
5//! rename must fail a test before it can break the TS types.
6//!
7//! Disabled-by-config semantics (`plugins.hot-update.enabled: false`):
8//! commands that *report or ack* state stay total — `current_bundle` answers
9//! truthfully (embedded, app version), `notify_app_ready` and `reset` are
10//! safe no-ops, so app boot code needs no dark-ship special-casing. Commands
11//! that *perform updates* (`check`, `download`) refuse with
12//! [`Error::Disabled`].
13
14use std::time::{Duration, Instant};
15
16use serde::{Deserialize, Serialize};
17use tauri::{AppHandle, Emitter, Runtime, State};
18
19use crate::machine::AckOutcome;
20use crate::runtime::{BundleSource, CurrentBundle, HotUpdate};
21use crate::update::{UpdateConfig, UpdateOutcome};
22use crate::{Error, Result};
23
24/// Event emitted while [`download`] streams the archive, carrying a
25/// [`DownloadProgress`] payload. Emission is throttled to at most one event
26/// per 100 ms so the IPC bridge is never flooded by per-chunk callbacks; the
27/// final chunk (`downloaded == total`) is always emitted.
28pub const PROGRESS_EVENT: &str = "hot-update://progress";
29
30const PROGRESS_MIN_INTERVAL: Duration = Duration::from_millis(100);
31
32/// Payload of [`PROGRESS_EVENT`]: archive bytes received so far out of the
33/// signed manifest's total.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct DownloadProgress {
37    pub downloaded: u64,
38    pub total: u64,
39}
40
41/// The validated update source, managed alongside [`HotUpdate`] by the
42/// plugin setup hook. `None` means disabled by config.
43pub(crate) struct CommandConfig {
44    pub(crate) update: Option<UpdateConfig>,
45}
46
47impl CommandConfig {
48    fn update_config(&self) -> Result<&UpdateConfig> {
49        self.update.as_ref().ok_or(Error::Disabled)
50    }
51
52    fn is_disabled(&self) -> bool {
53        self.update.is_none()
54    }
55}
56
57/// Wire shape of `notify_app_ready`, mirroring [`AckOutcome`]. A separate
58/// type because the machine's enum carries bare tuple payloads, which
59/// internally-tagged serde cannot represent — and because wire shapes belong
60/// to the IPC layer, not the pure state machine.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(
63    tag = "status",
64    rename_all = "camelCase",
65    rename_all_fields = "camelCase"
66)]
67pub(crate) enum AckResult {
68    Committed { seq: u64 },
69    AlreadyCommitted { seq: u64 },
70    EmbeddedNoop,
71    Stale { seq: u64 },
72}
73
74impl From<AckOutcome> for AckResult {
75    fn from(outcome: AckOutcome) -> Self {
76        match outcome {
77            AckOutcome::Committed(seq) => Self::Committed { seq },
78            AckOutcome::AlreadyCommitted(seq) => Self::AlreadyCommitted { seq },
79            AckOutcome::EmbeddedNoop => Self::EmbeddedNoop,
80            AckOutcome::Stale(seq) => Self::Stale { seq },
81        }
82    }
83}
84
85/// Rate limiter for progress emission: the first callback and anything at
86/// least `min_interval` after the previous emission pass; the final chunk
87/// always passes so the UI is guaranteed to see 100%.
88pub(crate) struct ProgressThrottle {
89    min_interval: Duration,
90    last_emit: Option<Instant>,
91}
92
93impl ProgressThrottle {
94    pub(crate) fn new(min_interval: Duration) -> Self {
95        Self {
96            min_interval,
97            last_emit: None,
98        }
99    }
100
101    pub(crate) fn should_emit(&mut self, downloaded: u64, total: u64) -> bool {
102        let is_final = downloaded >= total;
103        let due = self
104            .last_emit
105            .map_or(true, |at| at.elapsed() >= self.min_interval);
106        if is_final || due {
107            self.last_emit = Some(Instant::now());
108            return true;
109        }
110        false
111    }
112}
113
114/// Fetch and verify the manifest, then report whether an update applies.
115/// Never downloads the archive. Gate refusals (`upToDate`, `blacklisted`,
116/// `shellTooOld`, `alreadyStaged`) are `Ok` outcomes; errors are transport,
117/// verification, or config failures.
118#[tauri::command]
119pub(crate) async fn check(
120    hot_update: State<'_, HotUpdate>,
121    config: State<'_, CommandConfig>,
122) -> Result<UpdateOutcome> {
123    hot_update.check(config.update_config()?).await
124}
125
126/// The full pipeline: check, and if an update applies — download, verify,
127/// extract, and stage it for the next cold launch, emitting throttled
128/// [`PROGRESS_EVENT`]s along the way. Concurrent calls are serialized; the
129/// loser reports `alreadyStaged`/`upToDate` instead of downloading twice.
130#[tauri::command]
131pub(crate) async fn download<R: Runtime>(
132    app: AppHandle<R>,
133    hot_update: State<'_, HotUpdate>,
134    config: State<'_, CommandConfig>,
135) -> Result<UpdateOutcome> {
136    let mut throttle = ProgressThrottle::new(PROGRESS_MIN_INTERVAL);
137    hot_update
138        .check_and_download(config.update_config()?, |downloaded, total| {
139            if throttle.should_emit(downloaded, total) {
140                if let Err(e) = app.emit(PROGRESS_EVENT, DownloadProgress { downloaded, total }) {
141                    log::warn!("hot-update: failed to emit {PROGRESS_EVENT}: {e}");
142                }
143            }
144        })
145        .await
146}
147
148/// Commit the bundle this process booted (`notifyAppReady`). Idempotent, and
149/// deliberately safe to call unconditionally on every launch — including
150/// when serving embedded assets or when the plugin is disabled.
151#[tauri::command]
152pub(crate) fn notify_app_ready(
153    hot_update: State<'_, HotUpdate>,
154    config: State<'_, CommandConfig>,
155) -> Result<AckResult> {
156    if config.is_disabled() {
157        return Ok(AckResult::EmbeddedNoop);
158    }
159    Ok(hot_update.notify_app_ready()?.into())
160}
161
162/// What is being served right now. When the plugin is disabled this is, by
163/// definition, the embedded bundle at the app's own version.
164#[tauri::command]
165pub(crate) fn current_bundle<R: Runtime>(
166    app: AppHandle<R>,
167    hot_update: State<'_, HotUpdate>,
168    config: State<'_, CommandConfig>,
169) -> Result<CurrentBundle> {
170    if config.is_disabled() {
171        return Ok(CurrentBundle {
172            source: BundleSource::Embedded,
173            seq: None,
174            version: app.package_info().version.clone(),
175        });
176    }
177    hot_update.current_bundle()
178}
179
180/// Debug/support escape hatch: wipe all OTA state and bundles and revert to
181/// embedded serving on the next launch. A no-op while disabled (a disabled
182/// plugin owns no live state; leftovers from a previously enabled build are
183/// handled by boot resolution when re-enabled).
184#[tauri::command]
185pub(crate) fn reset(
186    hot_update: State<'_, HotUpdate>,
187    config: State<'_, CommandConfig>,
188) -> Result<()> {
189    if config.is_disabled() {
190        return Ok(());
191    }
192    hot_update.reset()
193}
194
195#[cfg(test)]
196mod tests;