Skip to main content

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;