1#![warn(missing_docs)]
36
37mod assets;
38mod commands;
39pub mod error;
41pub mod manifest;
43pub mod policy;
45pub 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
62pub 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
73pub type HotswapPlugin<R> = TauriPlugin<R, Value>;
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
98#[non_exhaustive]
99pub struct HotswapConfig {
100 pub endpoint: Option<String>,
103
104 pub pubkey: String,
106
107 #[serde(default)]
109 pub max_bundle_size: Option<u64>,
110
111 #[serde(default)]
113 pub require_https: Option<bool>,
114
115 #[serde(default)]
119 pub binary_cache_policy: Option<BinaryCachePolicyKind>,
120
121 #[serde(default)]
125 pub confirmation_policy: Option<ConfirmationPolicyKind>,
126
127 #[serde(default)]
130 pub rollback_policy: Option<RollbackPolicyKind>,
131
132 #[serde(default)]
135 pub max_retained_versions: Option<u32>,
136
137 #[serde(default)]
140 pub headers: Option<HashMap<String, String>>,
141
142 #[serde(default)]
146 pub channel: Option<String>,
147
148 #[serde(default)]
150 pub max_retries: Option<u32>,
151}
152
153impl HotswapConfig {
154 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 pub fn endpoint(mut self, url: impl Into<String>) -> Self {
173 self.endpoint = Some(url.into());
174 self
175 }
176
177 pub fn channel(mut self, channel: impl Into<String>) -> Self {
179 self.channel = Some(channel.into());
180 self
181 }
182
183 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
192pub(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 pub(crate) live_asset_dir: AssetDirHandle,
212 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
232pub 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
262pub 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
282pub 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 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 pub fn resolver(mut self, resolver: impl HotswapResolver) -> Self {
330 self.resolver = Some(Box::new(resolver));
331 self
332 }
333
334 pub fn max_bundle_size(mut self, bytes: u64) -> Self {
336 self.max_bundle_size = bytes;
337 self
338 }
339
340 pub fn require_https(mut self, require: bool) -> Self {
342 self.require_https = require;
343 self
344 }
345
346 pub fn binary_cache_policy(mut self, policy: impl BinaryCachePolicy) -> Self {
350 self.binary_cache_policy = Box::new(policy);
351 self
352 }
353
354 pub fn confirmation_policy(mut self, policy: impl ConfirmationPolicy) -> Self {
358 self.confirmation_policy = Box::new(policy);
359 self
360 }
361
362 pub fn rollback_policy(mut self, policy: impl RollbackPolicy) -> Self {
366 self.rollback_policy = Box::new(policy);
367 self
368 }
369
370 pub fn retention_policy(mut self, policy: impl RetentionPolicy) -> Self {
374 self.retention_policy = Box::new(policy);
375 self
376 }
377
378 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 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 pub fn channel(mut self, channel: impl Into<String>) -> Self {
395 self.channel = Some(channel.into());
396 self
397 }
398
399 pub fn max_retries(mut self, retries: u32) -> Self {
401 self.max_retries = retries;
402 self
403 }
404
405 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
439struct 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 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 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 #[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 #[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 #[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 #[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 #[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 let config: HotswapConfig = serde_json::from_value(json).unwrap();
642 assert_eq!(config.pubkey, "RWtest");
643 }
644}