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}