Skip to main content

tauri_plugin_hot_update/
lib.rs

1//! Hot update / OTA live updates for Tauri v2 mobile and desktop apps —
2//! CodePush-style, self-hosted.
3//!
4//! Serves the frontend from a downloaded OTA bundle when one is active,
5//! falling back to the embedded assets compiled from `frontendDist`. Updates
6//! apply on the next cold launch only, guarded by a three-state rollback
7//! pointer (`staged → booting → committed`): a bundle that never acks via
8//! [`HotUpdate::notify_app_ready`] is blacklisted by archive hash on the next
9//! boot and serving falls back to the last-known-good bundle or the embedded
10//! assets. One bad OTA push can never brick an installed app.
11//!
12//! # Integration (two steps)
13//!
14//! The assets swap must happen on the `Context` before `Builder::build`
15//! consumes it, while path resolution (the app data dir) is only available
16//! once the app is being built — hence two steps sharing one handle:
17//!
18//! ```rust,ignore
19//! fn main() {
20//!     let mut context = tauri::generate_context!();
21//!     // 1. Swap the embedded assets for the hot-update provider.
22//!     let hot_update = tauri_plugin_hot_update::install(&mut context);
23//!     tauri::Builder::default()
24//!         // 2. Register the plugin; its setup hook resolves the bundle
25//!         //    store and arms/rolls back BEFORE any webview exists.
26//!         .plugin(tauri_plugin_hot_update::init(hot_update))
27//!         .run(context)
28//!         .expect("error while running tauri application");
29//! }
30//! ```
31//!
32//! Configuration lives in `tauri.conf.json` (see [`Config`]) and is
33//! validated inside the setup hook — a malformed manifest URL or trust
34//! anchor aborts startup on the developer's machine, never silently in the
35//! field. The five IPC commands (`check`, `download`, `notify_app_ready`,
36//! `current_bundle`, `reset`; npm package `tauri-plugin-hot-update-api`)
37//! must be granted in a capability file, e.g. `"permissions":
38//! ["hot-update:default"]`.
39//!
40//! Register this plugin before other plugins so nothing observes assets
41//! earlier than the boot resolution. The frontend must call
42//! `notifyAppReady()` once the app shell has mounted and rendered —
43//! deliberately independent of network reachability or auth, so a backend
44//! outage can never condemn a good bundle fleet-wide.
45//!
46//! # Ordering guarantee
47//!
48//! Plugin setup hooks run inside `Builder::build` (tauri 2.10.2
49//! `app.rs:2289`), strictly before config windows and webviews are created
50//! (`app.rs:2373`). The staged→booting promotion is therefore persisted
51//! before the provider serves a single byte, on every platform — including
52//! Android, where the app data dir cannot be resolved before the app exists.
53
54use std::sync::Arc;
55
56use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
57use tauri::{Manager, Runtime};
58
59mod assets;
60mod commands;
61mod config;
62mod download;
63mod error;
64mod extract;
65mod machine;
66mod manifest;
67mod runtime;
68/// Release-side signing (the `hot-update-sign` CLI's core). Public only with
69/// the `cli` feature; also compiled for tests so the suite round-trips the
70/// real signing code against the verify path.
71#[cfg(any(feature = "cli", test))]
72pub mod sign;
73mod store;
74#[cfg(test)]
75mod testutil;
76mod update;
77
78pub use assets::HotUpdateAssets;
79pub use commands::{DownloadProgress, PROGRESS_EVENT};
80pub use config::Config;
81pub use error::{Error, Result};
82pub use extract::ExtractError;
83pub use machine::{AckOutcome, StageError};
84pub use manifest::{ArchiveInfo, Manifest};
85pub use runtime::{BundleSource, CurrentBundle, HotUpdate};
86pub use update::{UpdateConfig, UpdateOutcome};
87
88use runtime::Shared;
89
90/// Opaque link between [`install`] (which creates the assets provider) and
91/// [`init`] (which activates it once paths are resolvable).
92pub struct HotUpdateHandle {
93    shared: Arc<Shared>,
94}
95
96/// Step 1: swap the generated context's embedded assets for the hot-update
97/// provider. Must be called before `tauri::Builder::build`/`run` consumes
98/// the context. Pass the returned handle to [`init`].
99///
100/// Until [`init`]'s setup hook runs, the provider serves the embedded assets
101/// — the fail-safe floor.
102pub fn install<R: Runtime>(context: &mut tauri::Context<R>) -> HotUpdateHandle {
103    let shared = Arc::new(Shared::default());
104    // Two-step swap through a placeholder: the wrapper must own the original
105    // embedded box before it can be constructed.
106    let embedded = context.set_assets(Box::new(assets::PlaceholderAssets));
107    context.set_assets(Box::new(HotUpdateAssets::new(
108        embedded,
109        Arc::clone(&shared),
110    )));
111    HotUpdateHandle { shared }
112}
113
114/// Step 2: the plugin. Its setup hook (which tauri runs before any webview
115/// is created) validates the [`Config`] from `tauri.conf.json`, resolves
116/// `{app_data_dir}/hot-update`, loads `state.json`, performs
117/// rollback/arming, persists, and activates serving.
118///
119/// A missing or invalid `plugins.hot-update` config aborts startup (config
120/// is a build-time artifact — set `{ "enabled": false }` to dark-ship).
121/// *Runtime* failures (unresolvable data dir, unwritable state) degrade to
122/// serving embedded assets; they never abort the app.
123pub fn init<R: Runtime>(handle: HotUpdateHandle) -> TauriPlugin<R, Option<Config>> {
124    init_with_root(handle, None)
125}
126
127/// Test-only variant pinning the store root to a temp dir instead of the
128/// real `app_data_dir()` (which on a mock runtime resolves into the real
129/// user home).
130#[cfg(test)]
131pub(crate) fn init_for_test<R: Runtime>(
132    handle: HotUpdateHandle,
133    root: std::path::PathBuf,
134) -> TauriPlugin<R, Option<Config>> {
135    init_with_root(handle, Some(root))
136}
137
138fn init_with_root<R: Runtime>(
139    handle: HotUpdateHandle,
140    root_override: Option<std::path::PathBuf>,
141) -> TauriPlugin<R, Option<Config>> {
142    PluginBuilder::<R, Option<Config>>::new("hot-update")
143        .invoke_handler(tauri::generate_handler![
144            commands::check,
145            commands::download,
146            commands::notify_app_ready,
147            commands::current_bundle,
148            commands::reset,
149        ])
150        .setup(move |app, api| {
151            let config = api.config().as_ref().ok_or_else(|| {
152                Error::Config(
153                    "missing `plugins.hot-update` in tauri.conf.json; \
154                     set { \"enabled\": false } to ship the plugin dark"
155                        .into(),
156                )
157            })?;
158            let update = config.validate()?;
159            if update.is_some() {
160                let root = match &root_override {
161                    Some(root) => Some(root.clone()),
162                    None => match app.path().app_data_dir() {
163                        Ok(base) => Some(base.join("hot-update")),
164                        Err(e) => {
165                            log::error!(
166                                "hot-update: app_data_dir() failed ({e}); \
167                                 serving embedded assets only"
168                            );
169                            None
170                        }
171                    },
172                };
173                if let Some(root) = root {
174                    let embedded_version = app.package_info().version.clone();
175                    runtime::initialize(&handle.shared, root, embedded_version);
176                }
177            } else {
178                log::info!("hot-update: disabled by config; serving embedded assets");
179            }
180            app.manage(HotUpdate {
181                shared: Arc::clone(&handle.shared),
182            });
183            app.manage(commands::CommandConfig { update });
184            Ok(())
185        })
186        .build()
187}
188
189/// Access the [`HotUpdate`] runtime API from any `Manager` (app handle,
190/// window, webview).
191///
192/// Panics if the plugin was not registered via [`init`].
193pub trait HotUpdateExt<R: Runtime> {
194    fn hot_update(&self) -> &HotUpdate;
195}
196
197impl<R: Runtime, T: Manager<R>> HotUpdateExt<R> for T {
198    fn hot_update(&self) -> &HotUpdate {
199        self.state::<HotUpdate>().inner()
200    }
201}