Skip to main content

tauri_plugin_hot_update/
runtime.rs

1//! Boot-time wiring between the pure state machine and the I/O shell, plus
2//! the in-process runtime API ([`HotUpdate`]).
3//!
4//! Ordering guarantee (the anti-brick invariant): [`initialize`] runs inside
5//! the plugin's setup hook, which tauri executes during `Builder::build`
6//! (tauri 2.10.2 `app.rs:2289`; re-verified on 2.11.5 `app.rs:2440`) —
7//! strictly before any window or webview is created, and therefore before
8//! the assets provider serves a single byte. The staged→booting promotion
9//! is persisted inside [`initialize`], so by the time serving starts the rollback marker is
10//! already on disk. If persisting fails, the newly armed bundle is *not*
11//! served (a trial boot without a persisted marker could evade rollback).
12
13use std::path::PathBuf;
14use std::sync::{Arc, OnceLock};
15
16use semver::Version;
17use serde::Serialize;
18
19use crate::machine::{self, AckOutcome, Active};
20use crate::store::Store;
21use crate::{Error, Result};
22
23/// Cell shared between the assets provider (created at `install()` time,
24/// before the app exists) and the plugin (which resolves paths and runs boot
25/// resolution once the app is being built). Written exactly once.
26#[derive(Default)]
27pub(crate) struct Shared {
28    activation: OnceLock<Activation>,
29}
30
31/// Everything fixed for the lifetime of the process at boot resolution.
32pub(crate) struct Activation {
33    store: Store,
34    /// The source this process serves. Never changes mid-session — a
35    /// download that finishes later only stages for the *next* boot.
36    active: Active,
37    /// Bundle dir for [`Active::Ota`], resolved once.
38    active_dir: Option<PathBuf>,
39    /// Version of the active bundle (OTA) or of the embedded assets.
40    active_version: Version,
41    embedded_version: Version,
42    /// Serializes update pipeline runs ([`crate::update`]): concurrent
43    /// check/download calls must not race seq allocation or double-download.
44    update_lock: tokio::sync::Mutex<()>,
45}
46
47impl Activation {
48    pub(crate) fn store(&self) -> &Store {
49        &self.store
50    }
51
52    /// The shell version the compatibility gate checks `minShellVersion`
53    /// against. `version:bump` keeps the embedded frontend in lockstep with
54    /// the app version, so this is also the embedded bundle version.
55    pub(crate) fn embedded_version(&self) -> &Version {
56        &self.embedded_version
57    }
58
59    pub(crate) fn update_lock(&self) -> &tokio::sync::Mutex<()> {
60        &self.update_lock
61    }
62}
63
64impl Shared {
65    /// Directory the provider should serve from, or `None` for embedded.
66    /// `None` before activation: the fail-safe default is embedded.
67    pub(crate) fn active_dir(&self) -> Option<&PathBuf> {
68        self.activation.get()?.active_dir.as_ref()
69    }
70
71    pub(crate) fn is_activated(&self) -> bool {
72        self.activation.get().is_some()
73    }
74
75    pub(crate) fn activation(&self) -> Result<&Activation> {
76        self.activation.get().ok_or(Error::NotActive)
77    }
78}
79
80/// Run boot resolution against `root` (the `hot-update/` dir) and activate
81/// serving. Called from the plugin setup hook; must never panic or fail the
82/// app — every failure path degrades to serving embedded assets.
83pub(crate) fn initialize(shared: &Shared, root: PathBuf, embedded_version: Version) {
84    let store = Store::new(root);
85    let state = store.load_state();
86    let present = store.present_seqs();
87    let outcome = machine::resolve_boot(state, &embedded_version, &present);
88
89    if let Some(seq) = outcome.rolled_back {
90        log::warn!(
91            "hot-update: bundle seq {seq} was armed last boot but never acked; \
92             rolled back and blacklisted its archive hash"
93        );
94    }
95
96    // Persist BEFORE serving. On failure, fall back to what the previous
97    // persisted state already supports: the committed bundle survived boot
98    // validation and its pointer is already durable, but a *newly armed*
99    // bundle must not run without its rollback marker on disk.
100    let active = match store.save_state(&outcome.state) {
101        Ok(()) => {
102            store.apply_effects(&outcome.effects);
103            store.sweep_foreign_entries();
104            outcome.active
105        }
106        Err(e) => {
107            log::error!(
108                "hot-update: failed to persist state.json ({e}); \
109                 serving committed/embedded without arming the staged bundle"
110            );
111            match outcome.state.committed {
112                Some(seq) => Active::Ota(seq),
113                None => Active::Embedded,
114            }
115        }
116    };
117
118    let (active_dir, active_version) = match active {
119        Active::Ota(seq) => {
120            // resolve_boot only arms/keeps seqs it has metadata for.
121            let version = outcome
122                .state
123                .versions
124                .get(&seq)
125                .map(|meta| meta.version.clone())
126                .unwrap_or_else(|| embedded_version.clone());
127            (Some(store.bundle_dir(seq)), version)
128        }
129        Active::Embedded => (None, embedded_version.clone()),
130    };
131
132    match active {
133        Active::Ota(seq) => log::info!(
134            "hot-update: serving OTA bundle seq {seq} (v{active_version}) from {}",
135            store.bundle_dir(seq).display()
136        ),
137        Active::Embedded => {
138            log::info!("hot-update: serving embedded assets (v{embedded_version})")
139        }
140    }
141
142    let activation = Activation {
143        store,
144        active,
145        active_dir,
146        active_version,
147        embedded_version,
148        update_lock: tokio::sync::Mutex::new(()),
149    };
150    if shared.activation.set(activation).is_err() {
151        log::warn!("hot-update: initialize called twice; keeping the first activation");
152    }
153}
154
155/// Where the currently served frontend comes from.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157#[serde(rename_all = "lowercase")]
158pub enum BundleSource {
159    Embedded,
160    Ota,
161}
162
163/// Snapshot of what this process is serving.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct CurrentBundle {
167    pub source: BundleSource,
168    /// Bundle seq when `source == Ota`.
169    pub seq: Option<u64>,
170    pub version: Version,
171}
172
173/// Runtime API, managed in the app state by [`crate::init`]. IPC commands
174/// (WP4) will be thin wrappers over these methods.
175pub struct HotUpdate {
176    pub(crate) shared: Arc<Shared>,
177}
178
179impl HotUpdate {
180    /// Commit the bundle this process booted (`notifyAppReady`).
181    ///
182    /// Commits the in-memory booted seq captured at boot resolution — never
183    /// a value re-read from disk — so a download that finished mid-session
184    /// stays staged for its own trial boot. Idempotent.
185    pub fn notify_app_ready(&self) -> Result<AckOutcome> {
186        let activation = self.shared.activation()?;
187        let (outcome, effects) = activation.store.update(|state| {
188            let (state, outcome, effects) = machine::ack(state, activation.active);
189            (state, (outcome, effects))
190        })?;
191        activation.store.apply_effects(&effects);
192        match outcome {
193            AckOutcome::Committed(seq) => {
194                log::info!("hot-update: bundle seq {seq} committed as last-good")
195            }
196            AckOutcome::Stale(seq) => {
197                log::warn!("hot-update: ack for seq {seq} no longer matches on-disk state; ignored")
198            }
199            AckOutcome::AlreadyCommitted(_) | AckOutcome::EmbeddedNoop => {}
200        }
201        Ok(outcome)
202    }
203
204    /// What is being served right now.
205    pub fn current_bundle(&self) -> Result<CurrentBundle> {
206        let activation = self.shared.activation()?;
207        Ok(match activation.active {
208            Active::Ota(seq) => CurrentBundle {
209                source: BundleSource::Ota,
210                seq: Some(seq),
211                version: activation.active_version.clone(),
212            },
213            Active::Embedded => CurrentBundle {
214                source: BundleSource::Embedded,
215                seq: None,
216                version: activation.embedded_version.clone(),
217            },
218        })
219    }
220
221    /// Debug/support escape hatch: wipe all OTA state and bundles, restart
222    /// the watermark from the embedded version. The currently served bundle
223    /// dir (if any) is left in place until the next boot's GC so the running
224    /// webview is not torn; serving reverts to embedded on the next launch.
225    pub fn reset(&self) -> Result<()> {
226        let activation = self.shared.activation()?;
227        let effects = activation.store.update(|state| {
228            machine::reset(state, &activation.embedded_version, activation.active)
229        })?;
230        activation.store.apply_effects(&effects);
231        activation.store.sweep_foreign_entries();
232        log::info!("hot-update: state reset; embedded serving resumes next launch");
233        Ok(())
234    }
235}
236
237#[cfg(test)]
238mod tests;