Prechádzať zdrojové kódy

fix(utils): fix resources map becomes directory (#10293)

* fix(utils): fix resources map becomes directory

closes #10187

Fixes the behavior of mapped resources generating extra directory, for example:
`"../resources/user.json": "resources/user.json"` generates this resource `resources/user.json/user.json`
where it should generate `resources/user.json`

This PR includes a refactor of the Iterator implementation which splits it into more scoped functions and relis on recursing instead of a loop which makes the code a lot more readable and easier to maintain.

* clippy

* cover more cases

* clippy

* fix glob into directory, not resolving target correctly

* return error when resource origin path doesn't exist

* fix resources example build

* Update .changes/resources-map-becoming-dirs.md

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
Amr Bashir 11 mesiacov pred
rodič
commit
9e891933d8

+ 5 - 0
.changes/resources-map-becoming-dirs.md

@@ -0,0 +1,5 @@
+---
+"tauri-utils": "patch:bug"
+---
+
+Fix `ResourcePaths` iterator returning an unexpected result for mapped resources, for example `"../resources/user.json": "resources/user.json"` generates this resource `resources/user.json/user.json` where it should generate just `resources/user.json`.

+ 58 - 0
Cargo.lock

@@ -1005,6 +1005,21 @@ dependencies = [
  "new_debug_unreachable",
 ]
 
+[[package]]
+name = "futures"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.30"
@@ -1012,6 +1027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
 dependencies = [
  "futures-core",
+ "futures-sink",
 ]
 
 [[package]]
@@ -1066,6 +1082,7 @@ version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
 dependencies = [
+ "futures-channel",
  "futures-core",
  "futures-io",
  "futures-macro",
@@ -3174,6 +3191,15 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "scc"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f3281c67bce3cc354216537112a1571d2c28b9e7d744a07ef79b43fad64386c"
+dependencies = [
+ "sdd",
+]
+
 [[package]]
 name = "schannel"
 version = "0.1.23"
@@ -3220,6 +3246,12 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
+[[package]]
+name = "sdd"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9c386cfeafd20018fe07344e72dc4787f3432911e6c35d399457d86d2f146c4"
+
 [[package]]
 name = "security-framework"
 version = "2.11.0"
@@ -3388,6 +3420,31 @@ dependencies = [
  "syn 2.0.74",
 ]
 
+[[package]]
+name = "serial_test"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d"
+dependencies = [
+ "futures",
+ "log",
+ "once_cell",
+ "parking_lot",
+ "scc",
+ "serial_test_derive",
+]
+
+[[package]]
+name = "serial_test_derive"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.74",
+]
+
 [[package]]
 name = "serialize-to-javascript"
 version = "0.1.1"
@@ -3955,6 +4012,7 @@ dependencies = [
  "serde-untagged",
  "serde_json",
  "serde_with",
+ "serial_test",
  "serialize-to-javascript",
  "swift-rs",
  "thiserror",

+ 4 - 0
core/tauri-utils/Cargo.toml

@@ -47,6 +47,10 @@ serde-untagged = "0.1"
 [target."cfg(target_os = \"macos\")".dependencies]
 swift-rs = { version = "1.0.6", optional = true, features = [ "build" ] }
 
+[dev-dependencies]
+getrandom = { version = "0.2", features = [ "std" ] }
+serial_test = "3.1"
+
 [features]
 build = [
   "proc-macro2",

+ 4 - 0
core/tauri-utils/src/lib.rs

@@ -372,6 +372,10 @@ pub enum Error {
   #[cfg(feature = "resources")]
   #[error("could not walk directory `{0}`, try changing `allow_walk` to true on the `ResourcePaths` constructor.")]
   NotAllowedToWalkDir(std::path::PathBuf),
+  /// Resourece path doesn't exist
+  #[cfg(feature = "resources")]
+  #[error("resource path `{0}` doesn't exist")]
+  ResourcePathNotFound(std::path::PathBuf),
 }
 
 /// Reconstructs a path from its components using the platform separator then converts it to String and removes UNC prefixes on Windows if it exists.

+ 433 - 129
core/tauri-utils/src/resources.rs

@@ -7,6 +7,8 @@ use std::{
   path::{Component, Path, PathBuf},
 };
 
+use walkdir::WalkDir;
+
 /// Given a path (absolute or relative) to a resource file, returns the
 /// relative path from the bundle resources directory where that resource
 /// should be stored.
@@ -24,6 +26,20 @@ pub fn resource_relpath(path: &Path) -> PathBuf {
   dest
 }
 
+fn normalize(path: &Path) -> PathBuf {
+  let mut dest = PathBuf::new();
+  for component in path.components() {
+    match component {
+      Component::Prefix(_) => {}
+      Component::RootDir => dest.push("/"),
+      Component::CurDir => {}
+      Component::ParentDir => dest.push(".."),
+      Component::Normal(string) => dest.push(string),
+    }
+  }
+  dest
+}
+
 /// Parses the external binaries to bundle, adding the target triple suffix to each of them.
 pub fn external_binaries(external_binaries: &[String], target_triple: &str) -> Vec<String> {
   let mut paths = Vec::new();
@@ -42,6 +58,26 @@ pub fn external_binaries(external_binaries: &[String], target_triple: &str) -> V
   paths
 }
 
+/// Information for a resource.
+#[derive(Debug)]
+pub struct Resource {
+  path: PathBuf,
+  target: PathBuf,
+}
+
+impl Resource {
+  /// The path of the resource.
+  pub fn path(&self) -> &Path {
+    &self.path
+  }
+
+  /// The target location of the resource.
+  pub fn target(&self) -> &Path {
+    &self.target
+  }
+}
+
+#[derive(Debug)]
 enum PatternIter<'a> {
   Slice(std::slice::Iter<'a, String>),
   Map(std::collections::hash_map::Iter<'a, String, String>),
@@ -58,12 +94,12 @@ impl<'a> ResourcePaths<'a> {
     ResourcePaths {
       iter: ResourcePathsIter {
         pattern_iter: PatternIter::Slice(patterns.iter()),
-        glob_iter: None,
-        walk_iter: None,
         allow_walk,
+        current_path: None,
         current_pattern: None,
-        current_pattern_is_valid: false,
         current_dest: None,
+        walk_iter: None,
+        glob_iter: None,
       },
     }
   }
@@ -73,12 +109,12 @@ impl<'a> ResourcePaths<'a> {
     ResourcePaths {
       iter: ResourcePathsIter {
         pattern_iter: PatternIter::Map(patterns.iter()),
-        glob_iter: None,
-        walk_iter: None,
         allow_walk,
+        current_path: None,
         current_pattern: None,
-        current_pattern_is_valid: false,
         current_dest: None,
+        walk_iter: None,
+        glob_iter: None,
       },
     }
   }
@@ -91,38 +127,143 @@ impl<'a> ResourcePaths<'a> {
 }
 
 /// Iterator of a [`ResourcePaths`].
+#[derive(Debug)]
 pub struct ResourcePathsIter<'a> {
   /// the patterns to iterate.
   pattern_iter: PatternIter<'a>,
-  /// the glob iterator if the path from the current iteration is a glob pattern.
-  glob_iter: Option<glob::Paths>,
-  /// the walkdir iterator if the path from the current iteration is a directory.
-  walk_iter: Option<walkdir::IntoIter>,
   /// whether the resource paths allows directories or not.
   allow_walk: bool,
-  /// the pattern of the current iteration.
-  current_pattern: Option<(String, PathBuf)>,
-  /// whether the current pattern is valid or not.
-  current_pattern_is_valid: bool,
-  /// Current destination path. Only set when the iterator comes from a Map.
+
+  current_path: Option<PathBuf>,
+  current_pattern: Option<String>,
   current_dest: Option<PathBuf>,
-}
 
-/// Information for a resource.
-pub struct Resource {
-  path: PathBuf,
-  target: PathBuf,
+  walk_iter: Option<walkdir::IntoIter>,
+  glob_iter: Option<glob::Paths>,
 }
 
-impl Resource {
-  /// The path of the resource.
-  pub fn path(&self) -> &Path {
-    &self.path
+impl<'a> ResourcePathsIter<'a> {
+  fn next_glob_iter(&mut self) -> Option<crate::Result<Resource>> {
+    let entry = self.glob_iter.as_mut().unwrap().next()?;
+
+    let entry = match entry {
+      Ok(entry) => entry,
+      Err(err) => return Some(Err(err.into())),
+    };
+
+    self.current_path = Some(normalize(&entry));
+    self.next_current_path()
   }
 
-  /// The target location of the resource.
-  pub fn target(&self) -> &Path {
-    &self.target
+  fn next_walk_iter(&mut self) -> Option<crate::Result<Resource>> {
+    let entry = self.walk_iter.as_mut().unwrap().next()?;
+
+    let entry = match entry {
+      Ok(entry) => entry,
+      Err(err) => return Some(Err(err.into())),
+    };
+
+    self.current_path = Some(normalize(entry.path()));
+    self.next_current_path()
+  }
+
+  fn resource_from_path(&mut self, path: &Path) -> crate::Result<Resource> {
+    if !path.exists() {
+      return Err(crate::Error::ResourcePathNotFound(path.to_path_buf()));
+    }
+
+    Ok(Resource {
+      path: path.to_path_buf(),
+      target: self
+        .current_dest
+        .as_ref()
+        .map(|current_dest| {
+          // if processing a directory, preserve directory structure under current_dest
+          if self.walk_iter.is_some() {
+            let current_pattern = self.current_pattern.as_ref().unwrap();
+            current_dest.join(path.strip_prefix(current_pattern).unwrap_or(path))
+          } else if current_dest.components().count() == 0 {
+            // if current_dest is empty while processing a file pattern or glob
+            // we preserve the file name as it is
+            PathBuf::from(path.file_name().unwrap())
+          } else if self.glob_iter.is_some() {
+            // if processing a glob and current_dest is not empty
+            // we put all globbed paths under current_dest
+            // preserving the file name as it is
+            current_dest.join(path.file_name().unwrap())
+          } else {
+            current_dest.clone()
+          }
+        })
+        .unwrap_or_else(|| resource_relpath(path)),
+    })
+  }
+
+  fn next_current_path(&mut self) -> Option<crate::Result<Resource>> {
+    // should be safe to unwrap since every call to `self.next_current_path()`
+    // is preceeded with assignemt to `self.current_path`
+    let path = self.current_path.take().unwrap();
+
+    let is_dir = path.is_dir();
+
+    if is_dir {
+      if self.glob_iter.is_some() {
+        return self.next();
+      }
+
+      if !self.allow_walk {
+        return Some(Err(crate::Error::NotAllowedToWalkDir(path.to_path_buf())));
+      }
+
+      if self.walk_iter.is_none() {
+        self.walk_iter = Some(WalkDir::new(&path).into_iter());
+      }
+
+      match self.next_walk_iter() {
+        Some(resource) => Some(resource),
+        None => {
+          self.walk_iter = None;
+          self.next()
+        }
+      }
+    } else {
+      Some(self.resource_from_path(&path))
+    }
+  }
+
+  fn next_pattern(&mut self) -> Option<crate::Result<Resource>> {
+    self.current_pattern = None;
+    self.current_dest = None;
+    self.current_path = None;
+
+    let pattern = match &mut self.pattern_iter {
+      PatternIter::Slice(iter) => match iter.next() {
+        Some(pattern) => pattern,
+        None => return None,
+      },
+      PatternIter::Map(iter) => match iter.next() {
+        Some((pattern, dest)) => {
+          self.current_pattern = Some(pattern.clone());
+          self.current_dest = Some(resource_relpath(Path::new(dest)));
+          pattern
+        }
+        None => return None,
+      },
+    };
+
+    if pattern.contains('*') {
+      self.glob_iter = match glob::glob(pattern) {
+        Ok(glob) => Some(glob),
+        Err(error) => return Some(Err(error.into())),
+      };
+      match self.next_glob_iter() {
+        Some(r) => return Some(r),
+        None => self.glob_iter = None,
+      }
+    }
+
+    self.current_path = Some(normalize(Path::new(pattern)));
+    self.next_current_path()
   }
 }
 
@@ -134,116 +275,279 @@ impl<'a> Iterator for ResourcePaths<'a> {
   }
 }
 
-fn normalize(path: &Path) -> PathBuf {
-  let mut dest = PathBuf::new();
-  for component in path.components() {
-    match component {
-      Component::Prefix(_) => {}
-      Component::RootDir => dest.push("/"),
-      Component::CurDir => {}
-      Component::ParentDir => dest.push(".."),
-      Component::Normal(string) => dest.push(string),
+impl<'a> Iterator for ResourcePathsIter<'a> {
+  type Item = crate::Result<Resource>;
+
+  fn next(&mut self) -> Option<crate::Result<Resource>> {
+    if self.current_path.is_some() {
+      return self.next_current_path();
     }
+
+    if self.walk_iter.is_some() {
+      match self.next_walk_iter() {
+        Some(r) => return Some(r),
+        None => self.walk_iter = None,
+      }
+    }
+
+    if self.glob_iter.is_some() {
+      match self.next_glob_iter() {
+        Some(r) => return Some(r),
+        None => self.glob_iter = None,
+      }
+    }
+
+    self.next_pattern()
   }
-  dest
 }
 
-impl<'a> Iterator for ResourcePathsIter<'a> {
-  type Item = crate::Result<Resource>;
+#[cfg(test)]
+mod tests {
 
-  fn next(&mut self) -> Option<crate::Result<Resource>> {
-    loop {
-      if let Some(ref mut walk_entries) = self.walk_iter {
-        if let Some(entry) = walk_entries.next() {
-          let entry = match entry {
-            Ok(entry) => entry,
-            Err(error) => return Some(Err(crate::Error::from(error))),
-          };
-          let path = entry.path();
-          if path.is_dir() {
-            continue;
-          }
-          self.current_pattern_is_valid = true;
-          return Some(Ok(Resource {
-            target: if let (Some(current_dest), Some(current_pattern)) =
-              (&self.current_dest, &self.current_pattern)
-            {
-              if current_pattern.0.contains('*') {
-                current_dest.join(path.file_name().unwrap())
-              } else {
-                current_dest.join(path.strip_prefix(&current_pattern.1).unwrap())
-              }
-            } else {
-              resource_relpath(path)
-            },
-            path: path.to_path_buf(),
-          }));
-        }
+  use super::*;
+  use std::fs;
+  use std::path::Path;
+
+  impl PartialEq for Resource {
+    fn eq(&self, other: &Self) -> bool {
+      self.path == other.path && self.target == other.target
+    }
+  }
+
+  fn expected_resources(resources: &[(&str, &str)]) -> Vec<Resource> {
+    resources
+      .iter()
+      .map(|(path, target)| Resource {
+        path: Path::new(path).components().collect(),
+        target: Path::new(target).components().collect(),
+      })
+      .collect()
+  }
+
+  fn setup_test_dirs() {
+    let mut random = [0; 1];
+    getrandom::getrandom(&mut random).unwrap();
+
+    let temp = std::env::temp_dir();
+    let temp = temp.join(format!("tauri_resource_paths_iter_test_{}", random[0]));
+
+    let _ = fs::remove_dir_all(&temp);
+    fs::create_dir_all(&temp).unwrap();
+
+    std::env::set_current_dir(&temp).unwrap();
+
+    let paths = [
+      Path::new("src-tauri/tauri.conf.json"),
+      Path::new("src-tauri/some-other-json.json"),
+      Path::new("src-tauri/Cargo.toml"),
+      Path::new("src-tauri/Tauri.toml"),
+      Path::new("src-tauri/build.rs"),
+      Path::new("src/assets/javascript.svg"),
+      Path::new("src/assets/tauri.svg"),
+      Path::new("src/assets/rust.svg"),
+      Path::new("src/assets/lang/en.json"),
+      Path::new("src/assets/lang/ar.json"),
+      Path::new("src/sounds/lang/es.wav"),
+      Path::new("src/sounds/lang/fr.wav"),
+      Path::new("src/textures/ground/earth.tex"),
+      Path::new("src/textures/ground/sand.tex"),
+      Path::new("src/textures/water.tex"),
+      Path::new("src/textures/fire.tex"),
+      Path::new("src/tiles/sky/grey.tile"),
+      Path::new("src/tiles/sky/yellow.tile"),
+      Path::new("src/tiles/grass.tile"),
+      Path::new("src/tiles/stones.tile"),
+      Path::new("src/index.html"),
+      Path::new("src/style.css"),
+      Path::new("src/script.js"),
+    ];
+
+    for path in paths {
+      fs::create_dir_all(path.parent().unwrap()).unwrap();
+      fs::write(path, "").unwrap();
+    }
+  }
+
+  #[test]
+  #[serial_test::serial]
+  fn resource_paths_iter_slice_allow_walk() {
+    setup_test_dirs();
+
+    let dir = std::env::current_dir().unwrap().join("src-tauri");
+    let _ = std::env::set_current_dir(dir);
+
+    let resources = ResourcePaths::new(
+      &[
+        "../src/script.js".into(),
+        "../src/assets".into(),
+        "../src/index.html".into(),
+        "../src/sounds".into(),
+        "*.toml".into(),
+        "*.conf.json".into(),
+      ],
+      true,
+    )
+    .iter()
+    .flatten()
+    .collect::<Vec<_>>();
+
+    let expected = expected_resources(&[
+      ("../src/script.js", "_up_/src/script.js"),
+      (
+        "../src/assets/javascript.svg",
+        "_up_/src/assets/javascript.svg",
+      ),
+      ("../src/assets/tauri.svg", "_up_/src/assets/tauri.svg"),
+      ("../src/assets/rust.svg", "_up_/src/assets/rust.svg"),
+      ("../src/assets/lang/en.json", "_up_/src/assets/lang/en.json"),
+      ("../src/assets/lang/ar.json", "_up_/src/assets/lang/ar.json"),
+      ("../src/index.html", "_up_/src/index.html"),
+      ("../src/sounds/lang/es.wav", "_up_/src/sounds/lang/es.wav"),
+      ("../src/sounds/lang/fr.wav", "_up_/src/sounds/lang/fr.wav"),
+      ("Cargo.toml", "Cargo.toml"),
+      ("Tauri.toml", "Tauri.toml"),
+      ("tauri.conf.json", "tauri.conf.json"),
+    ]);
+
+    assert_eq!(resources.len(), expected.len());
+    for resource in expected {
+      if !resources.contains(&resource) {
+        panic!("{resource:?} was expected but not found in {resources:?}");
       }
-      self.walk_iter = None;
-      if let Some(ref mut glob_paths) = self.glob_iter {
-        if let Some(glob_result) = glob_paths.next() {
-          let path = match glob_result {
-            Ok(path) => path,
-            Err(error) => return Some(Err(error.into())),
-          };
-          if path.is_dir() {
-            if self.allow_walk {
-              let walk = walkdir::WalkDir::new(path);
-              self.walk_iter = Some(walk.into_iter());
-              continue;
-            } else {
-              return Some(Err(crate::Error::NotAllowedToWalkDir(path)));
-            }
-          }
-          self.current_pattern_is_valid = true;
-          return Some(Ok(Resource {
-            target: if let Some(current_dest) = &self.current_dest {
-              current_dest.join(path.file_name().unwrap())
-            } else {
-              resource_relpath(&path)
-            },
-            path,
-          }));
-        } else if let Some(current_path) = &self.current_pattern {
-          if !self.current_pattern_is_valid {
-            self.glob_iter = None;
-            return Some(Err(crate::Error::GlobPathNotFound(current_path.0.clone())));
-          }
-        }
+    }
+  }
+
+  #[test]
+  #[serial_test::serial]
+  fn resource_paths_iter_slice_no_walk() {
+    setup_test_dirs();
+
+    let dir = std::env::current_dir().unwrap().join("src-tauri");
+    let _ = std::env::set_current_dir(dir);
+
+    let resources = ResourcePaths::new(
+      &[
+        "../src/script.js".into(),
+        "../src/assets".into(),
+        "../src/index.html".into(),
+        "../src/sounds".into(),
+        "*.toml".into(),
+        "*.conf.json".into(),
+      ],
+      false,
+    )
+    .iter()
+    .flatten()
+    .collect::<Vec<_>>();
+
+    let expected = expected_resources(&[
+      ("../src/script.js", "_up_/src/script.js"),
+      ("../src/index.html", "_up_/src/index.html"),
+      ("Cargo.toml", "Cargo.toml"),
+      ("Tauri.toml", "Tauri.toml"),
+      ("tauri.conf.json", "tauri.conf.json"),
+    ]);
+
+    assert_eq!(resources.len(), expected.len());
+    for resource in expected {
+      if !resources.contains(&resource) {
+        panic!("{resource:?} was expected but not found in {resources:?}");
       }
-      self.glob_iter = None;
-      self.current_dest = None;
-      match &mut self.pattern_iter {
-        PatternIter::Slice(iter) => {
-          if let Some(pattern) = iter.next() {
-            self.current_pattern = Some((pattern.to_string(), normalize(Path::new(pattern))));
-            self.current_pattern_is_valid = false;
-            let glob = match glob::glob(pattern) {
-              Ok(glob) => glob,
-              Err(error) => return Some(Err(error.into())),
-            };
-            self.glob_iter = Some(glob);
-            continue;
-          }
-        }
-        PatternIter::Map(iter) => {
-          if let Some((pattern, dest)) = iter.next() {
-            self.current_pattern = Some((pattern.to_string(), normalize(Path::new(pattern))));
-            self.current_pattern_is_valid = false;
-            let glob = match glob::glob(pattern) {
-              Ok(glob) => glob,
-              Err(error) => return Some(Err(error.into())),
-            };
-            self
-              .current_dest
-              .replace(resource_relpath(&PathBuf::from(dest)));
-            self.glob_iter = Some(glob);
-            continue;
-          }
-        }
+    }
+  }
+
+  #[test]
+  #[serial_test::serial]
+  fn resource_paths_iter_map_allow_walk() {
+    setup_test_dirs();
+
+    let dir = std::env::current_dir().unwrap().join("src-tauri");
+    let _ = std::env::set_current_dir(dir);
+
+    let resources = ResourcePaths::from_map(
+      &std::collections::HashMap::from_iter([
+        ("../src/script.js".into(), "main.js".into()),
+        ("../src/assets".into(), "".into()),
+        ("../src/index.html".into(), "frontend/index.html".into()),
+        ("../src/sounds".into(), "voices".into()),
+        ("../src/textures/*".into(), "textures".into()),
+        ("../src/tiles/**/*".into(), "tiles".into()),
+        ("*.toml".into(), "".into()),
+        ("*.conf.json".into(), "json".into()),
+        ("../non-existent-file".into(), "asd".into()), // invalid case
+        ("../non/*".into(), "asd".into()),             // invalid case
+      ]),
+      true,
+    )
+    .iter()
+    .flatten()
+    .collect::<Vec<_>>();
+
+    let expected = expected_resources(&[
+      ("../src/script.js", "main.js"),
+      ("../src/assets/javascript.svg", "javascript.svg"),
+      ("../src/assets/tauri.svg", "tauri.svg"),
+      ("../src/assets/rust.svg", "rust.svg"),
+      ("../src/assets/lang/en.json", "lang/en.json"),
+      ("../src/assets/lang/ar.json", "lang/ar.json"),
+      ("../src/index.html", "frontend/index.html"),
+      ("../src/sounds/lang/es.wav", "voices/lang/es.wav"),
+      ("../src/sounds/lang/fr.wav", "voices/lang/fr.wav"),
+      ("../src/textures/water.tex", "textures/water.tex"),
+      ("../src/textures/fire.tex", "textures/fire.tex"),
+      ("../src/tiles/grass.tile", "tiles/grass.tile"),
+      ("../src/tiles/stones.tile", "tiles/stones.tile"),
+      ("../src/tiles/sky/grey.tile", "tiles/grey.tile"),
+      ("../src/tiles/sky/yellow.tile", "tiles/yellow.tile"),
+      ("Cargo.toml", "Cargo.toml"),
+      ("Tauri.toml", "Tauri.toml"),
+      ("tauri.conf.json", "json/tauri.conf.json"),
+    ]);
+
+    assert_eq!(resources.len(), expected.len());
+    for resource in expected {
+      if !resources.contains(&resource) {
+        panic!("{resource:?} was expected but not found in {resources:?}");
+      }
+    }
+  }
+
+  #[test]
+  #[serial_test::serial]
+  fn resource_paths_iter_map_no_walk() {
+    setup_test_dirs();
+
+    let dir = std::env::current_dir().unwrap().join("src-tauri");
+    let _ = std::env::set_current_dir(dir);
+
+    let resources = ResourcePaths::from_map(
+      &std::collections::HashMap::from_iter([
+        ("../src/script.js".into(), "main.js".into()),
+        ("../src/assets".into(), "".into()),
+        ("../src/index.html".into(), "frontend/index.html".into()),
+        ("../src/sounds".into(), "voices".into()),
+        ("*.toml".into(), "".into()),
+        ("*.conf.json".into(), "json".into()),
+      ]),
+      false,
+    )
+    .iter()
+    .flatten()
+    .collect::<Vec<_>>();
+
+    let expected = expected_resources(&[
+      ("../src/script.js", "main.js"),
+      ("../src/index.html", "frontend/index.html"),
+      ("Cargo.toml", "Cargo.toml"),
+      ("Tauri.toml", "Tauri.toml"),
+      ("tauri.conf.json", "json/tauri.conf.json"),
+    ]);
+
+    assert_eq!(resources.len(), expected.len());
+    for resource in expected {
+      if !resources.contains(&resource) {
+        panic!("{resource:?} was expected but not found in {resources:?}");
       }
-      return None;
     }
   }
 }

+ 1 - 1
examples/resources/src-tauri/src/main.rs

@@ -8,7 +8,7 @@ use std::{
   io::{BufRead, BufReader},
   process::{Command, Stdio},
 };
-use tauri::Manager;
+use tauri::{Emitter, Manager};
 
 fn main() {
   tauri::Builder::default()