tauri_plugin_hot_update/assets.rs
1//! The `Assets` provider: active-OTA-bundle-dir first, embedded fallback.
2//!
3//! This is the single call site behind the `tauri://` scheme handler on every
4//! platform, so the origin never changes and web storage (auth/session state)
5//! is preserved by construction. SPA-route fallback (`/foo` →
6//! `/foo/index.html` → `/index.html`) lives in tauri's `get_asset`, which
7//! retries `get()` per candidate — the provider answers exact keys only.
8//!
9//! CSP rule (design doc, settled 2026-07-09): `csp_hashes()` is keyed to the
10//! active source *per key*. When a key is served from the OTA bundle the
11//! compile-time hashes (computed from the embedded HTML) would be stale, so
12//! an empty iterator is returned — leaving a configured `'unsafe-inline'`
13//! active, exactly like a plain web deploy. When a key falls back to the
14//! embedded assets, the embedded hashes are delegated to. Never stale hashes
15//! for fresh content.
16
17use std::borrow::Cow;
18use std::path::{Component, Path, PathBuf};
19use std::sync::Arc;
20
21use tauri::utils::assets::{AssetKey, AssetsIter, CspHash};
22use tauri::{App, Assets, Runtime};
23
24use crate::runtime::Shared;
25
26/// Assets provider wrapping the embedded `EmbeddedAssets` taken from the
27/// generated `Context` (via the official `Context::set_assets`).
28pub struct HotUpdateAssets<R: Runtime> {
29 embedded: Box<dyn Assets<R>>,
30 shared: Arc<Shared>,
31}
32
33impl<R: Runtime> HotUpdateAssets<R> {
34 pub(crate) fn new(embedded: Box<dyn Assets<R>>, shared: Arc<Shared>) -> Self {
35 Self { embedded, shared }
36 }
37
38 /// The on-disk file this key resolves to, iff an OTA bundle is active
39 /// and the bundle contains the file.
40 fn ota_file(&self, key: &AssetKey) -> Option<PathBuf> {
41 let dir = self.shared.active_dir()?;
42 let path = safe_join(dir, key.as_ref())?;
43 path.is_file().then_some(path)
44 }
45}
46
47impl<R: Runtime> Assets<R> for HotUpdateAssets<R> {
48 fn setup(&self, app: &App<R>) {
49 // Tauri calls this after the config windows are created — too late to
50 // resolve anything, but a good place to detect a mis-wired app.
51 if !self.shared.is_activated() {
52 log::error!(
53 "hot-update: assets provider installed but the plugin never initialized — \
54 did you forget .plugin(tauri_plugin_hot_update::init(handle))? \
55 Serving embedded assets only"
56 );
57 }
58 self.embedded.setup(app);
59 }
60
61 fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
62 if let Some(path) = self.ota_file(key) {
63 match std::fs::read(&path) {
64 Ok(bytes) => {
65 log::debug!("hot-update: {} served from OTA bundle", key.as_ref());
66 return Some(Cow::Owned(bytes));
67 }
68 Err(e) => {
69 log::warn!(
70 "hot-update: reading {} failed ({e}); falling back to embedded",
71 path.display()
72 );
73 }
74 }
75 }
76 self.embedded.get(key)
77 }
78
79 fn iter(&self) -> Box<AssetsIter<'_>> {
80 // Only used by `AssetResolver::iter` / dev tooling, never by the
81 // serving path; delegating to embedded keeps it deterministic.
82 self.embedded.iter()
83 }
84
85 fn csp_hashes(&self, html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
86 if self.ota_file(html_path).is_some() {
87 // OTA-served HTML: compile-time hashes would be stale — empty.
88 Box::new(std::iter::empty())
89 } else {
90 // Embedded-served HTML: the compile-time hashes match exactly.
91 self.embedded.csp_hashes(html_path)
92 }
93 }
94}
95
96/// Never served: occupies `Context::assets` for the instant between taking
97/// the embedded assets out and installing the wrapper (the wrapper must own
98/// the original box before it can be constructed).
99pub(crate) struct PlaceholderAssets;
100
101impl<R: Runtime> Assets<R> for PlaceholderAssets {
102 fn get(&self, _key: &AssetKey) -> Option<Cow<'_, [u8]>> {
103 None
104 }
105 fn iter(&self) -> Box<AssetsIter<'_>> {
106 Box::new(std::iter::empty())
107 }
108 fn csp_hashes(&self, _html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
109 Box::new(std::iter::empty())
110 }
111}
112
113/// Join a normalized asset key onto the bundle dir, refusing anything that
114/// could escape it. `AssetKey` is already normalized by tauri (rooted, unix
115/// separators), but this is the serving layer — be strict regardless: every
116/// component must be a plain name (no `..`, no `.`, no root/prefix
117/// components such as Windows drive letters).
118fn safe_join(dir: &Path, key: &str) -> Option<PathBuf> {
119 let rel = key.trim_start_matches('/');
120 if rel.is_empty() {
121 return None;
122 }
123 let mut out = dir.to_path_buf();
124 for component in Path::new(rel).components() {
125 match component {
126 Component::Normal(part) => out.push(part),
127 _ => return None,
128 }
129 }
130 Some(out)
131}
132
133#[cfg(test)]
134mod tests;