tauri_codegen/
embedded_assets.rs

1// Copyright 2019-2021 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use kuchiki::traits::*;
6use proc_macro2::TokenStream;
7use quote::{quote, ToTokens, TokenStreamExt};
8use regex::RegexSet;
9use std::{
10  collections::HashMap,
11  ffi::OsStr,
12  fs::File,
13  path::{Path, PathBuf},
14};
15use tauri_utils::{
16  assets::AssetKey,
17  html::{inject_csp, inject_invoke_key_token},
18};
19use thiserror::Error;
20use walkdir::WalkDir;
21
22/// The subdirectory inside the target directory we want to place assets.
23const TARGET_PATH: &str = "tauri-codegen-assets";
24
25/// The minimum size needed for the hasher to use multiple threads.
26const MULTI_HASH_SIZE_LIMIT: usize = 131_072; // 128KiB
27
28/// (key, (original filepath, compressed bytes))
29type Asset = (AssetKey, (PathBuf, PathBuf));
30
31/// All possible errors while reading and compressing an [`EmbeddedAssets`] directory
32#[derive(Debug, Error)]
33#[non_exhaustive]
34pub enum EmbeddedAssetsError {
35  #[error("failed to read asset at {path} because {error}")]
36  AssetRead {
37    path: PathBuf,
38    error: std::io::Error,
39  },
40
41  #[error("failed to write asset from {path} to Vec<u8> because {error}")]
42  AssetWrite {
43    path: PathBuf,
44    error: std::io::Error,
45  },
46
47  #[error("invalid prefix {prefix} used while including path {path}")]
48  PrefixInvalid { prefix: PathBuf, path: PathBuf },
49
50  #[error("failed to walk directory {path} because {error}")]
51  Walkdir {
52    path: PathBuf,
53    error: walkdir::Error,
54  },
55
56  #[error("OUT_DIR env var is not set, do you have a build script?")]
57  OutDir,
58}
59
60/// Represent a directory of assets that are compressed and embedded.
61///
62/// This is the compile time generation of [`tauri_utils::assets::Assets`] from a directory. Assets
63/// from the directory are added as compiler dependencies by dummy including the original,
64/// uncompressed assets.
65///
66/// The assets are compressed during this runtime, and can only be represented as a [`TokenStream`]
67/// through [`ToTokens`]. The generated code is meant to be injected into an application to include
68/// the compressed assets in that application's binary.
69#[derive(Default)]
70pub struct EmbeddedAssets(HashMap<AssetKey, (PathBuf, PathBuf)>);
71
72/// Options used to embed assets.
73#[derive(Default)]
74pub struct AssetOptions {
75  csp: Option<String>,
76}
77
78impl AssetOptions {
79  /// Creates the default asset options.
80  pub fn new() -> Self {
81    Self::default()
82  }
83
84  /// Sets the content security policy to add to HTML files.
85  pub fn csp(mut self, csp: String) -> Self {
86    self.csp.replace(csp);
87    self
88  }
89}
90
91impl EmbeddedAssets {
92  /// Compress a directory of assets, ready to be generated into a [`tauri_utils::assets::Assets`].
93  pub fn new(path: &Path, options: AssetOptions) -> Result<Self, EmbeddedAssetsError> {
94    WalkDir::new(&path)
95      .follow_links(true)
96      .into_iter()
97      .filter_map(|entry| match entry {
98        // we only serve files, not directory listings
99        Ok(entry) if entry.file_type().is_dir() => None,
100
101        // compress all files encountered
102        Ok(entry) => Some(Self::compress_file(path, entry.path(), &options)),
103
104        // pass down error through filter to fail when encountering any error
105        Err(error) => Some(Err(EmbeddedAssetsError::Walkdir {
106          path: path.to_owned(),
107          error,
108        })),
109      })
110      .collect::<Result<_, _>>()
111      .map(Self)
112  }
113
114  /// Compress a list of files and directories.
115  pub fn load_paths(
116    paths: Vec<PathBuf>,
117    options: AssetOptions,
118  ) -> Result<Self, EmbeddedAssetsError> {
119    Ok(Self(
120      paths
121        .iter()
122        .map(|path| {
123          let is_file = path.is_file();
124          WalkDir::new(&path)
125            .follow_links(true)
126            .into_iter()
127            .filter_map(|entry| {
128              match entry {
129                // we only serve files, not directory listings
130                Ok(entry) if entry.file_type().is_dir() => None,
131
132                // compress all files encountered
133                Ok(entry) => Some(Self::compress_file(
134                  if is_file {
135                    path.parent().unwrap()
136                  } else {
137                    path
138                  },
139                  entry.path(),
140                  &options,
141                )),
142
143                // pass down error through filter to fail when encountering any error
144                Err(error) => Some(Err(EmbeddedAssetsError::Walkdir {
145                  path: path.to_path_buf(),
146                  error,
147                })),
148              }
149            })
150            .collect::<Result<Vec<Asset>, _>>()
151        })
152        .flatten()
153        .flatten()
154        .collect::<_>(),
155    ))
156  }
157
158  /// Use highest compression level for release, the fastest one for everything else
159  fn compression_level() -> i32 {
160    let levels = zstd::compression_level_range();
161    if cfg!(debug_assertions) {
162      *levels.start()
163    } else {
164      *levels.end()
165    }
166  }
167
168  /// Compress a file and spit out the information in a [`HashMap`] friendly form.
169  fn compress_file(
170    prefix: &Path,
171    path: &Path,
172    options: &AssetOptions,
173  ) -> Result<Asset, EmbeddedAssetsError> {
174    let mut input = std::fs::read(path).map_err(|error| EmbeddedAssetsError::AssetRead {
175      path: path.to_owned(),
176      error,
177    })?;
178    if path.extension() == Some(OsStr::new("html")) {
179      let mut document = kuchiki::parse_html().one(String::from_utf8_lossy(&input).into_owned());
180      if let Some(csp) = &options.csp {
181        inject_csp(&mut document, csp);
182      }
183      inject_invoke_key_token(&mut document);
184      input = document.to_string().as_bytes().to_vec();
185    } else {
186      let is_javascript = ["js", "cjs", "mjs"]
187        .iter()
188        .any(|e| path.extension() == Some(OsStr::new(e)));
189      if is_javascript {
190        let js = String::from_utf8_lossy(&input).into_owned();
191        input = if RegexSet::new(&[
192          // import keywords
193          "import\\{",
194          "import \\{",
195          "import\\*",
196          "import \\*",
197          "import (\"|');?$",
198          "import\\(",
199          "import (.|\n)+ from (\"|')([A-Za-z/\\.@-]+)(\"|')",
200          // export keywords
201          "export\\{",
202          "export \\{",
203          "export\\*",
204          "export \\*",
205          "export (default|class|let|const|function)",
206        ])
207        .unwrap()
208        .is_match(&js)
209        {
210          format!(
211            r#"
212              const __TAURI_INVOKE_KEY__ = __TAURI__INVOKE_KEY_TOKEN__;
213              {}
214            "#,
215            js
216          )
217          .as_bytes()
218          .to_vec()
219        } else {
220          format!(
221            r#"(function () {{
222              const __TAURI_INVOKE_KEY__ = __TAURI__INVOKE_KEY_TOKEN__;
223              {}
224            }})()"#,
225            js
226          )
227          .as_bytes()
228          .to_vec()
229        };
230      }
231    }
232
233    // we must canonicalize the base of our paths to allow long paths on windows
234    let out_dir = std::env::var("OUT_DIR")
235      .map_err(|_| EmbeddedAssetsError::OutDir)
236      .map(PathBuf::from)
237      .and_then(|p| p.canonicalize().map_err(|_| EmbeddedAssetsError::OutDir))
238      .map(|p| p.join(TARGET_PATH))?;
239
240    // make sure that our output directory is created
241    std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
242
243    // get a hash of the input - allows for caching existing files
244    let hash = {
245      let mut hasher = blake3::Hasher::new();
246      if input.len() < MULTI_HASH_SIZE_LIMIT {
247        hasher.update(&input);
248      } else {
249        hasher.update_rayon(&input);
250      }
251      hasher.finalize().to_hex()
252    };
253
254    // use the content hash to determine filename, keep extensions that exist
255    let out_path = if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
256      out_dir.join(format!("{}.{}", hash, ext))
257    } else {
258      out_dir.join(hash.to_string())
259    };
260
261    // only compress and write to the file if it doesn't already exist.
262    if !out_path.exists() {
263      let out_file = File::create(&out_path).map_err(|error| EmbeddedAssetsError::AssetWrite {
264        path: out_path.clone(),
265        error,
266      })?;
267
268      // entirely write input to the output file path with compression
269      zstd::stream::copy_encode(&*input, out_file, Self::compression_level()).map_err(|error| {
270        EmbeddedAssetsError::AssetWrite {
271          path: path.to_owned(),
272          error,
273        }
274      })?;
275    }
276
277    // get a key to the asset path without the asset directory prefix
278    let key = path
279      .strip_prefix(prefix)
280      .map(AssetKey::from) // format the path for use in assets
281      .map_err(|_| EmbeddedAssetsError::PrefixInvalid {
282        prefix: prefix.to_owned(),
283        path: path.to_owned(),
284      })?;
285
286    Ok((key, (path.into(), out_path)))
287  }
288}
289
290impl ToTokens for EmbeddedAssets {
291  fn to_tokens(&self, tokens: &mut TokenStream) {
292    let mut map = TokenStream::new();
293    for (key, (input, output)) in &self.0 {
294      let key: &str = key.as_ref();
295      let input = input.display().to_string();
296      let output = output.display().to_string();
297
298      // add original asset as a compiler dependency, rely on dead code elimination to clean it up
299      map.append_all(quote!(#key => {
300        const _: &[u8] = include_bytes!(#input);
301        include_bytes!(#output)
302      },));
303    }
304
305    // we expect phf related items to be in path when generating the path code
306    tokens.append_all(quote! {{
307        use ::tauri::api::assets::{EmbeddedAssets, phf, phf::phf_map};
308        EmbeddedAssets::from_zstd(phf_map! { #map })
309    }});
310  }
311}