Skip to main content

tauri_plugin_hotswap/
lib.rs

1//! # tauri-plugin-hotswap
2//!
3//! Hot-swap frontend assets at runtime without rebuilding the binary.
4//!
5//! This Tauri v2 plugin swaps the embedded asset provider at startup via
6//! `Context::set_assets()`. The WebView keeps loading from `tauri://localhost` —
7//! the swap is transparent. If no cached bundle is available, embedded assets
8//! are served as usual.
9//!
10//! ## Quick start
11//!
12//! ```json
13//! // tauri.conf.json
14//! {
15//!   "plugins": {
16//!     "hotswap": {
17//!       "endpoint": "https://example.com/api/ota/{{current_sequence}}",
18//!       "pubkey": "<YOUR_MINISIGN_PUBKEY>"
19//!     }
20//!   }
21//! }
22//! ```
23//!
24//! ```rust,ignore
25//! let context = tauri::generate_context!();
26//! let (plugin, context) = tauri_plugin_hotswap::init(context)
27//!     .expect("failed to initialize hotswap plugin");
28//!
29//! tauri::Builder::default()
30//!     .plugin(plugin)
31//!     .run(context)
32//!     .expect("error running app");
33//! ```
34
35#![warn(missing_docs)]
36
37mod assets;
38mod commands;
39/// Error types returned by the plugin.
40pub mod error;
41/// Manifest and response types exchanged between client and server.
42pub mod manifest;
43/// Configurable policy traits for OTA update lifecycle decisions.
44pub mod policy;
45/// Resolver trait and built-in implementations for update checking.
46pub mod resolver;
47mod updater;
48
49use std::collections::HashMap;
50use std::fmt;
51use std::path::PathBuf;
52use std::sync::{Arc, Mutex, RwLock};
53
54use assets::{AssetDirHandle, EmptyAssets};
55use serde::{Deserialize, Serialize};
56use serde_json::Value;
57use tauri::{
58    plugin::{Builder, TauriPlugin},
59    Manager, Runtime,
60};
61
62// Re-export all public types at the crate root for convenience.
63pub use assets::HotswapAssets;
64pub use error::Error;
65pub use manifest::{HotswapCheckResult, HotswapManifest, HotswapMeta, HotswapVersionInfo};
66pub use policy::{
67    BinaryCachePolicy, BinaryCachePolicyKind, ConfirmationDecision, ConfirmationPolicy,
68    ConfirmationPolicyKind, RetentionConfig, RetentionPolicy, RollbackPolicy, RollbackPolicyKind,
69};
70pub use resolver::{CheckContext, HotswapResolver, HttpResolver, StaticFileResolver};
71pub use updater::{DownloadProgress, LifecycleEvent};
72
73/// Plugin instance type returned by initialization helpers.
74///
75/// The plugin builder intentionally uses `serde_json::Value` for the plugin
76/// API config type so Tauri accepts `plugins.hotswap` as `null`, `{}`, or a
77/// full config object across all initialization paths.
78pub type HotswapPlugin<R> = TauriPlugin<R, Value>;
79
80/// Configuration that can be specified in `tauri.conf.json` under
81/// `plugins.hotswap`, or passed programmatically.
82///
83/// # Example (tauri.conf.json)
84///
85/// ```json
86/// {
87///   "plugins": {
88///     "hotswap": {
89///       "endpoint": "https://example.com/api/ota/{{current_sequence}}",
90///       "pubkey": "<YOUR_MINISIGN_PUBKEY>",
91///       "channel": "production",
92///       "headers": { "Authorization": "Bearer <token>" }
93///     }
94///   }
95/// }
96/// ```
97#[derive(Debug, Clone, Deserialize, Serialize)]
98#[non_exhaustive]
99pub struct HotswapConfig {
100    /// The update check endpoint URL.
101    /// Use `{{current_sequence}}` as a placeholder.
102    pub endpoint: Option<String>,
103
104    /// The minisign public key (RW... base64 line).
105    pub pubkey: String,
106
107    /// Maximum allowed bundle size in bytes. Default: 512 MB.
108    #[serde(default)]
109    pub max_bundle_size: Option<u64>,
110
111    /// Whether to reject non-HTTPS URLs. Default: true.
112    #[serde(default)]
113    pub require_https: Option<bool>,
114
115    /// Binary cache policy. Controls whether cached OTA bundles are discarded
116    /// when the binary version changes.
117    /// Options: `keep_compatible`, `discard_on_upgrade`, `never_discard`.
118    #[serde(default)]
119    pub binary_cache_policy: Option<BinaryCachePolicyKind>,
120
121    /// Confirmation policy. Controls what happens on startup if the current
122    /// OTA version hasn't been confirmed via `notifyReady()`.
123    /// Options: `single_launch` (default), `{ "grace_period": { "max_unconfirmed_launches": N } }`.
124    #[serde(default)]
125    pub confirmation_policy: Option<ConfirmationPolicyKind>,
126
127    /// Rollback policy. Controls which version to roll back to.
128    /// Options: `latest_confirmed` (default), `immediate_previous_confirmed`, `embedded_only`.
129    #[serde(default)]
130    pub rollback_policy: Option<RollbackPolicyKind>,
131
132    /// Maximum number of OTA versions to retain on disk. Default: 2, min: 2.
133    /// Includes current and rollback candidate.
134    #[serde(default)]
135    pub max_retained_versions: Option<u32>,
136
137    /// Custom HTTP headers sent on check and download requests.
138    /// Use for auth tokens, API keys, etc.
139    #[serde(default)]
140    pub headers: Option<HashMap<String, String>>,
141
142    /// Update channel (e.g. "production", "staging", "beta").
143    /// Sent as a query param on check requests. Can be changed at
144    /// runtime via `configure()`.
145    #[serde(default)]
146    pub channel: Option<String>,
147
148    /// Maximum download retry attempts with exponential backoff. Default: 3.
149    #[serde(default)]
150    pub max_retries: Option<u32>,
151}
152
153impl HotswapConfig {
154    /// Create a new config with the given public key and sensible defaults.
155    pub fn new(pubkey: impl Into<String>) -> Self {
156        Self {
157            endpoint: None,
158            pubkey: pubkey.into(),
159            max_bundle_size: None,
160            require_https: None,
161            binary_cache_policy: None,
162            confirmation_policy: None,
163            rollback_policy: None,
164            max_retained_versions: None,
165            headers: None,
166            channel: None,
167            max_retries: None,
168        }
169    }
170
171    /// Set the update check endpoint URL.
172    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
173        self.endpoint = Some(url.into());
174        self
175    }
176
177    /// Set the update channel.
178    pub fn channel(mut self, channel: impl Into<String>) -> Self {
179        self.channel = Some(channel.into());
180        self
181    }
182
183    /// Add a custom header sent on every request.
184    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
185        self.headers
186            .get_or_insert_with(HashMap::new)
187            .insert(key.into(), value.into());
188        self
189    }
190}
191
192/// Plugin state managed by Tauri, accessible from commands.
193pub(crate) struct HotswapState {
194    pub(crate) resolver: Box<dyn HotswapResolver>,
195    pub(crate) pubkey: String,
196    pub(crate) binary_version: String,
197    pub(crate) base_dir: PathBuf,
198    pub(crate) max_bundle_size: u64,
199    pub(crate) require_https: bool,
200    pub(crate) max_retries: u32,
201    pub(crate) http_client: reqwest::Client,
202    pub(crate) custom_headers: Mutex<HashMap<String, String>>,
203    pub(crate) channel: Mutex<Option<String>>,
204    pub(crate) endpoint_override: Mutex<Option<String>>,
205    pub(crate) current_sequence: Mutex<u64>,
206    pub(crate) current_version: Mutex<Option<String>>,
207    pub(crate) pending_manifest: Mutex<Option<HotswapManifest>>,
208    /// Shared handle to the live asset directory used by `HotswapAssets`.
209    /// Updated by apply/activate/rollback so `window.location.reload()`
210    /// immediately serves the new assets without an app restart.
211    pub(crate) live_asset_dir: AssetDirHandle,
212    // Policy traits
213    pub(crate) rollback_policy: Box<dyn RollbackPolicy>,
214    pub(crate) retention_policy: Box<dyn RetentionPolicy>,
215}
216
217impl fmt::Debug for HotswapState {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.debug_struct("HotswapState")
220            .field("binary_version", &self.binary_version)
221            .field("base_dir", &self.base_dir)
222            .field("max_bundle_size", &self.max_bundle_size)
223            .field("require_https", &self.require_https)
224            .field("max_retries", &self.max_retries)
225            .field("channel", &self.channel)
226            .field("current_sequence", &self.current_sequence)
227            .field("current_version", &self.current_version)
228            .finish_non_exhaustive()
229    }
230}
231
232/// Initialize the plugin by reading config from `tauri.conf.json`.
233///
234/// Returns an error if the config is missing, invalid, or the endpoint
235/// URL violates `require_https`.
236pub fn init<R: Runtime>(
237    context: tauri::Context<R>,
238) -> Result<(HotswapPlugin<R>, tauri::Context<R>), Error> {
239    let config: HotswapConfig = context
240        .config()
241        .plugins
242        .0
243        .get("hotswap")
244        .and_then(|v| serde_json::from_value(v.clone()).ok())
245        .ok_or_else(|| {
246            Error::Config("missing or invalid 'plugins.hotswap' in tauri.conf.json".into())
247        })?;
248
249    let endpoint = config
250        .endpoint
251        .clone()
252        .ok_or_else(|| Error::Config("'endpoint' is required in plugins.hotswap config".into()))?;
253
254    let mut resolver = HttpResolver::new(endpoint);
255    if let Some(ref headers) = config.headers {
256        resolver = resolver.with_headers(headers.clone());
257    }
258
259    build_plugin(context, Box::new(resolver), config, None)
260}
261
262/// Initialize with explicit config (no tauri.conf.json needed).
263///
264/// Returns an error if the endpoint URL violates `require_https`.
265pub fn init_with_config<R: Runtime>(
266    context: tauri::Context<R>,
267    config: HotswapConfig,
268) -> Result<(HotswapPlugin<R>, tauri::Context<R>), Error> {
269    let endpoint = config
270        .endpoint
271        .clone()
272        .ok_or_else(|| Error::Config("'endpoint' is required in HotswapConfig".into()))?;
273
274    let mut resolver = HttpResolver::new(endpoint);
275    if let Some(ref headers) = config.headers {
276        resolver = resolver.with_headers(headers.clone());
277    }
278
279    build_plugin(context, Box::new(resolver), config, None)
280}
281
282/// Builder for advanced usage with a custom resolver.
283///
284/// # Example
285///
286/// ```rust,ignore
287/// let (plugin, context) = tauri_plugin_hotswap::HotswapBuilder::new("<YOUR_MINISIGN_PUBKEY>")
288///     .resolver(tauri_plugin_hotswap::StaticFileResolver::new(
289///         "https://cdn.example.com/ota/latest.json",
290///     ))
291///     .channel("production")
292///     .header("Authorization", "Bearer <token>")
293///     .build(context)
294///     .expect("failed to init hotswap");
295/// ```
296pub struct HotswapBuilder {
297    pubkey: String,
298    resolver: Option<Box<dyn HotswapResolver>>,
299    max_bundle_size: u64,
300    require_https: bool,
301    binary_cache_policy: Box<dyn BinaryCachePolicy>,
302    confirmation_policy: Box<dyn ConfirmationPolicy>,
303    rollback_policy: Box<dyn RollbackPolicy>,
304    retention_policy: Box<dyn RetentionPolicy>,
305    headers: HashMap<String, String>,
306    channel: Option<String>,
307    max_retries: u32,
308}
309
310impl HotswapBuilder {
311    /// Create a new builder with the given minisign public key.
312    pub fn new(pubkey: impl Into<String>) -> Self {
313        Self {
314            pubkey: pubkey.into(),
315            resolver: None,
316            max_bundle_size: updater::DEFAULT_MAX_BUNDLE_SIZE,
317            require_https: true,
318            binary_cache_policy: Box::new(BinaryCachePolicyKind::DiscardOnUpgrade),
319            confirmation_policy: Box::new(ConfirmationPolicyKind::default()),
320            rollback_policy: Box::new(RollbackPolicyKind::default()),
321            retention_policy: Box::new(RetentionConfig::default()),
322            headers: HashMap::new(),
323            channel: None,
324            max_retries: updater::DEFAULT_MAX_RETRIES,
325        }
326    }
327
328    /// Set the update resolver.
329    pub fn resolver(mut self, resolver: impl HotswapResolver) -> Self {
330        self.resolver = Some(Box::new(resolver));
331        self
332    }
333
334    /// Set the maximum allowed bundle size in bytes. Default: 512 MB.
335    pub fn max_bundle_size(mut self, bytes: u64) -> Self {
336        self.max_bundle_size = bytes;
337        self
338    }
339
340    /// Whether to reject non-HTTPS URLs. Default: true.
341    pub fn require_https(mut self, require: bool) -> Self {
342        self.require_https = require;
343        self
344    }
345
346    /// Set the binary cache policy. Default: `DiscardOnUpgrade`.
347    /// Accepts any type implementing [`BinaryCachePolicy`], including
348    /// the built-in [`BinaryCachePolicyKind`] enum or a custom implementation.
349    pub fn binary_cache_policy(mut self, policy: impl BinaryCachePolicy) -> Self {
350        self.binary_cache_policy = Box::new(policy);
351        self
352    }
353
354    /// Set the confirmation policy. Default: `SingleLaunch`.
355    /// Accepts any type implementing [`ConfirmationPolicy`], including
356    /// the built-in [`ConfirmationPolicyKind`] enum or a custom implementation.
357    pub fn confirmation_policy(mut self, policy: impl ConfirmationPolicy) -> Self {
358        self.confirmation_policy = Box::new(policy);
359        self
360    }
361
362    /// Set the rollback policy. Default: `LatestConfirmed`.
363    /// Accepts any type implementing [`RollbackPolicy`], including
364    /// the built-in [`RollbackPolicyKind`] enum or a custom implementation.
365    pub fn rollback_policy(mut self, policy: impl RollbackPolicy) -> Self {
366        self.rollback_policy = Box::new(policy);
367        self
368    }
369
370    /// Set the retention policy. Default: `RetentionConfig { max_retained_versions: 2 }`.
371    /// Accepts any type implementing [`RetentionPolicy`], including
372    /// the built-in [`RetentionConfig`] or a custom implementation.
373    pub fn retention_policy(mut self, policy: impl RetentionPolicy) -> Self {
374        self.retention_policy = Box::new(policy);
375        self
376    }
377
378    /// Set the maximum number of retained versions. Default: 2, min: 2.
379    /// Shorthand for `retention_policy(RetentionConfig { max_retained_versions: count })`.
380    pub fn max_retained_versions(mut self, count: u32) -> Self {
381        self.retention_policy = Box::new(RetentionConfig {
382            max_retained_versions: count,
383        });
384        self
385    }
386
387    /// Add a custom header sent on download requests.
388    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
389        self.headers.insert(key.into(), value.into());
390        self
391    }
392
393    /// Set the update channel.
394    pub fn channel(mut self, channel: impl Into<String>) -> Self {
395        self.channel = Some(channel.into());
396        self
397    }
398
399    /// Set the maximum download retry attempts. Default: 3.
400    pub fn max_retries(mut self, retries: u32) -> Self {
401        self.max_retries = retries;
402        self
403    }
404
405    /// Build the plugin. Returns an error if configuration is invalid.
406    pub fn build<R: Runtime>(
407        self,
408        context: tauri::Context<R>,
409    ) -> Result<(HotswapPlugin<R>, tauri::Context<R>), Error> {
410        let resolver = self
411            .resolver
412            .ok_or_else(|| Error::Config("a resolver must be set via .resolver()".into()))?;
413
414        let config = HotswapConfig {
415            endpoint: None,
416            pubkey: self.pubkey,
417            max_bundle_size: Some(self.max_bundle_size),
418            require_https: Some(self.require_https),
419            binary_cache_policy: None,
420            confirmation_policy: None,
421            rollback_policy: None,
422            max_retained_versions: None,
423            headers: Some(self.headers),
424            channel: self.channel,
425            max_retries: Some(self.max_retries),
426        };
427
428        let policies = ResolvedPolicies {
429            binary_cache: self.binary_cache_policy,
430            confirmation: self.confirmation_policy,
431            rollback: self.rollback_policy,
432            retention: self.retention_policy,
433        };
434
435        build_plugin(context, resolver, config, Some(policies))
436    }
437}
438
439/// Pre-resolved boxed policies (from builder path).
440struct ResolvedPolicies {
441    binary_cache: Box<dyn BinaryCachePolicy>,
442    confirmation: Box<dyn ConfirmationPolicy>,
443    rollback: Box<dyn RollbackPolicy>,
444    retention: Box<dyn RetentionPolicy>,
445}
446
447fn build_plugin<R: Runtime>(
448    mut context: tauri::Context<R>,
449    resolver: Box<dyn HotswapResolver>,
450    config: HotswapConfig,
451    override_policies: Option<ResolvedPolicies>,
452) -> Result<(HotswapPlugin<R>, tauri::Context<R>), Error> {
453    let binary_version = context.config().version.clone().unwrap_or_default();
454    let app_id = context.config().identifier.clone();
455    let base_dir = resolve_base_dir(&app_id);
456    let max_bundle_size = config
457        .max_bundle_size
458        .unwrap_or(updater::DEFAULT_MAX_BUNDLE_SIZE);
459    let require_https = config.require_https.unwrap_or(true);
460    let custom_headers = config.headers.unwrap_or_default();
461    let channel = config.channel.clone();
462    let max_retries = config.max_retries.unwrap_or(updater::DEFAULT_MAX_RETRIES);
463
464    // Resolve policies: builder overrides take precedence over config
465    let policies = override_policies.unwrap_or_else(|| ResolvedPolicies {
466        binary_cache: Box::new(
467            config
468                .binary_cache_policy
469                .unwrap_or(BinaryCachePolicyKind::DiscardOnUpgrade),
470        ),
471        confirmation: Box::new(config.confirmation_policy.unwrap_or_default()),
472        rollback: Box::new(config.rollback_policy.unwrap_or_default()),
473        retention: Box::new(RetentionConfig {
474            max_retained_versions: config.max_retained_versions.unwrap_or(2),
475        }),
476    });
477
478    if binary_version.is_empty() {
479        log::warn!(
480            "[hotswap] No 'version' set in tauri.conf.json. \
481             Binary compatibility checks will not work correctly."
482        );
483    }
484
485    if require_https {
486        if let Some(ref endpoint) = config.endpoint {
487            if !endpoint.starts_with("https://") {
488                return Err(Error::InsecureUrl(endpoint.clone()));
489            }
490        }
491    }
492
493    let _ = std::fs::create_dir_all(&base_dir);
494
495    let ota_dir = updater::check_compatibility(
496        &base_dir,
497        &binary_version,
498        &*policies.binary_cache,
499        &*policies.confirmation,
500        &*policies.rollback,
501    );
502    let meta = ota_dir.as_ref().and_then(|d| updater::read_meta(d));
503    let current_sequence = meta.as_ref().map(|m| m.sequence).unwrap_or(0);
504    let current_version = meta.map(|m| m.version);
505
506    // Shared handle: HotswapAssets reads from this on every request,
507    // and commands (apply/activate/rollback) update it at runtime.
508    let live_asset_dir: AssetDirHandle = Arc::new(RwLock::new(ota_dir));
509
510    let embedded: Box<dyn tauri::Assets<R>> =
511        std::mem::replace(&mut context.assets, Box::new(EmptyAssets));
512    context.assets = Box::new(HotswapAssets::new(embedded, Arc::clone(&live_asset_dir)));
513
514    let pubkey = config.pubkey.clone();
515    let binary_version_clone = binary_version.clone();
516    let base_dir_clone = base_dir.clone();
517    let current_sequence_clone = current_sequence;
518    let current_version_clone = current_version.clone();
519    let http_client = reqwest::Client::new();
520
521    let plugin = Builder::<R, Value>::new("hotswap")
522        .invoke_handler(tauri::generate_handler![
523            commands::hotswap_check,
524            commands::hotswap_apply,
525            commands::hotswap_download,
526            commands::hotswap_activate,
527            commands::hotswap_rollback,
528            commands::hotswap_current_version,
529            commands::hotswap_notify_ready,
530            commands::hotswap_configure,
531            commands::hotswap_get_config,
532        ])
533        .setup(move |app, _api| {
534            app.manage(HotswapState {
535                resolver,
536                pubkey,
537                binary_version: binary_version_clone,
538                base_dir: base_dir_clone,
539                max_bundle_size,
540                require_https,
541                max_retries,
542                http_client,
543                custom_headers: Mutex::new(custom_headers),
544                channel: Mutex::new(channel),
545                endpoint_override: Mutex::new(None),
546                current_sequence: Mutex::new(current_sequence_clone),
547                current_version: Mutex::new(current_version_clone),
548                pending_manifest: Mutex::new(None),
549                live_asset_dir,
550                rollback_policy: policies.rollback,
551                retention_policy: policies.retention,
552            });
553            Ok(())
554        })
555        .build();
556
557    Ok((plugin, context))
558}
559
560fn resolve_base_dir(app_id: &str) -> PathBuf {
561    #[cfg(not(target_os = "android"))]
562    {
563        let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
564        base.join(app_id).join("hotswap")
565    }
566    #[cfg(target_os = "android")]
567    {
568        PathBuf::from("/data/data")
569            .join(app_id)
570            .join("files/hotswap")
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// Regression: `HotswapConfig` must deserialize from a full JSON object.
579    /// This is the Option A path (config in tauri.conf.json).
580    #[test]
581    fn config_deserializes_from_json_object() {
582        let json = serde_json::json!({
583            "endpoint": "https://example.com/ota/{{current_sequence}}",
584            "pubkey": "RWtest",
585            "channel": "beta",
586            "binary_cache_policy": "keep_compatible",
587            "confirmation_policy": "single_launch",
588            "rollback_policy": "latest_confirmed",
589            "max_retained_versions": 3
590        });
591        let config: HotswapConfig = serde_json::from_value(json).unwrap();
592        assert_eq!(config.pubkey, "RWtest");
593        assert_eq!(
594            config.binary_cache_policy,
595            Some(BinaryCachePolicyKind::KeepCompatible)
596        );
597        assert_eq!(config.max_retained_versions, Some(3));
598    }
599
600    /// Regression: `serde_json::Value` (the plugin builder config type)
601    /// must deserialize from `null`. This is the Option B/C path when
602    /// `plugins.hotswap` is absent from tauri.conf.json.
603    #[test]
604    fn value_deserializes_from_null() {
605        let result: serde_json::Value = serde_json::from_str("null").unwrap();
606        assert!(result.is_null());
607    }
608
609    /// Regression: `serde_json::Value` must deserialize from a JSON object.
610    /// This is the Option A path on mobile where Tauri re-deserializes
611    /// `plugins.hotswap` during `Builder::run()`.
612    #[test]
613    fn value_deserializes_from_object() {
614        let json = r#"{"endpoint":"https://example.com","pubkey":"RWtest"}"#;
615        let result: serde_json::Value = serde_json::from_str(json).unwrap();
616        assert!(result.is_object());
617    }
618
619    /// Regression: `HotswapConfig` with only required fields.
620    #[test]
621    fn config_minimal() {
622        let json = serde_json::json!({"pubkey": "RWtest"});
623        let config: HotswapConfig = serde_json::from_value(json).unwrap();
624        assert_eq!(config.pubkey, "RWtest");
625        assert!(config.endpoint.is_none());
626        assert!(config.binary_cache_policy.is_none());
627        assert!(config.confirmation_policy.is_none());
628        assert!(config.rollback_policy.is_none());
629        assert!(config.max_retained_versions.is_none());
630    }
631
632    /// Regression: `HotswapConfig` with unknown future fields should not fail.
633    #[test]
634    fn config_ignores_unknown_fields() {
635        let json = serde_json::json!({
636            "pubkey": "RWtest",
637            "some_future_field": true,
638            "another_field": 42
639        });
640        // Should not error — serde default behavior is to ignore unknown fields
641        let config: HotswapConfig = serde_json::from_value(json).unwrap();
642        assert_eq!(config.pubkey, "RWtest");
643    }
644}