浏览代码

prevent unnecessary rebuilds when working in the cargo workspace (#10442)

* hash codegen image cache output

* remove left over dbg! statement

* prevent info.plist from workspace rebuilds

* prevent schema generation from workspace rebuilds

* use new `Cached` struct in `CachedIcon`

* fmt

* use full import for cached plist

* use `to_vec()` for raw icons
chip 1 年之前
父节点
当前提交
b32295de18

+ 23 - 23
core/tauri-acl-schema/build.rs

@@ -2,33 +2,33 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
-use std::{
-  error::Error,
-  fs::File,
-  io::{BufWriter, Write},
-  path::PathBuf,
-};
-
-use schemars::schema::RootSchema;
-
-pub fn main() -> Result<(), Box<dyn Error>> {
-  let cap_schema = schemars::schema_for!(tauri_utils::acl::capability::Capability);
-  let perm_schema = schemars::schema_for!(tauri_utils::acl::Permission);
-  let scope_schema = schemars::schema_for!(tauri_utils::acl::Scopes);
+use std::{error::Error, path::PathBuf};
 
-  let crate_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
-
-  write_schema_file(cap_schema, crate_dir.join("capability-schema.json"))?;
-  write_schema_file(perm_schema, crate_dir.join("permission-schema.json"))?;
-  write_schema_file(scope_schema, crate_dir.join("scope-schema.json"))?;
+use schemars::schema_for;
+use tauri_utils::{
+  acl::capability::Capability,
+  acl::{Permission, Scopes},
+  write_if_changed,
+};
 
-  Ok(())
+macro_rules! schema {
+  ($name:literal, $path:ty) => {
+    (concat!($name, "-schema.json"), schema_for!($path))
+  };
 }
 
-fn write_schema_file(schema: RootSchema, outpath: PathBuf) -> Result<(), Box<dyn Error>> {
-  let schema_str = serde_json::to_string_pretty(&schema).unwrap();
-  let mut schema_file = BufWriter::new(File::create(outpath)?);
-  write!(schema_file, "{schema_str}")?;
+pub fn main() -> Result<(), Box<dyn Error>> {
+  let schemas = [
+    schema!("capability", Capability),
+    schema!("permission", Permission),
+    schema!("scope", Scopes),
+  ];
+
+  let out = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
+  for (filename, schema) in schemas {
+    let schema = serde_json::to_string_pretty(&schema)?;
+    write_if_changed(out.join(filename), schema)?;
+  }
 
   Ok(())
 }

+ 29 - 32
core/tauri-codegen/src/context.rs

@@ -7,28 +7,28 @@ use std::convert::identity;
 use std::path::{Path, PathBuf};
 use std::{ffi::OsStr, str::FromStr};
 
+use crate::{
+  embedded_assets::{
+    ensure_out_dir, AssetOptions, CspHashes, EmbeddedAssets, EmbeddedAssetsResult,
+  },
+  image::CachedIcon,
+};
 use base64::Engine;
 use proc_macro2::TokenStream;
 use quote::quote;
 use sha2::{Digest, Sha256};
-
 use syn::Expr;
-use tauri_utils::acl::capability::{Capability, CapabilityFile};
-use tauri_utils::acl::manifest::Manifest;
-use tauri_utils::acl::resolved::Resolved;
-use tauri_utils::assets::AssetKey;
-use tauri_utils::config::{CapabilityEntry, Config, FrontendDist, PatternKind};
-use tauri_utils::html::{
-  inject_nonce_token, parse as parse_html, serialize_node as serialize_html_node, NodeRef,
-};
-use tauri_utils::platform::Target;
-use tauri_utils::plugin::GLOBAL_API_SCRIPT_FILE_LIST_PATH;
-use tauri_utils::tokens::{map_lit, str_lit};
-
-use crate::embedded_assets::{
-  ensure_out_dir, AssetOptions, CspHashes, EmbeddedAssets, EmbeddedAssetsResult,
+use tauri_utils::{
+  acl::capability::{Capability, CapabilityFile},
+  acl::manifest::Manifest,
+  acl::resolved::Resolved,
+  assets::AssetKey,
+  config::{CapabilityEntry, Config, FrontendDist, PatternKind},
+  html::{inject_nonce_token, parse as parse_html, serialize_node as serialize_html_node, NodeRef},
+  platform::Target,
+  plugin::GLOBAL_API_SCRIPT_FILE_LIST_PATH,
+  tokens::{map_lit, str_lit},
 };
-use crate::image::{ico_icon, image_icon, png_icon, raw_icon};
 
 const ACL_MANIFESTS_FILE_NAME: &str = "acl-manifests.json";
 const CAPABILITIES_FILE_NAME: &str = "capabilities.json";
@@ -221,8 +221,8 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
         "icons/icon.ico",
       );
       if icon_path.exists() {
-        ico_icon(&root, &out_dir, &icon_path, "default-window-icon.png")
-          .map(|i| quote!(::std::option::Option::Some(#i)))?
+        let icon = CachedIcon::new(&root, &icon_path)?;
+        quote!(::std::option::Option::Some(#icon))
       } else {
         let icon_path = find_icon(
           &config,
@@ -230,8 +230,8 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
           |i| i.ends_with(".png"),
           "icons/icon.png",
         );
-        png_icon(&root, &out_dir, &icon_path, "default-window-icon.png")
-          .map(|i| quote!(::std::option::Option::Some(#i)))?
+        let icon = CachedIcon::new(&root, &icon_path)?;
+        quote!(::std::option::Option::Some(#icon))
       }
     } else {
       // handle default window icons for Unix targets
@@ -241,8 +241,8 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
         |i| i.ends_with(".png"),
         "icons/icon.png",
       );
-      png_icon(&root, &out_dir, &icon_path, "default-window-icon.png")
-        .map(|i| quote!(::std::option::Option::Some(#i)))?
+      let icon = CachedIcon::new(&root, &icon_path)?;
+      quote!(::std::option::Option::Some(#icon))
     }
   };
 
@@ -261,7 +261,9 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
         "icons/icon.png",
       );
     }
-    raw_icon(&out_dir, &icon_path, "dev-macos-icon.png")?
+
+    let icon = CachedIcon::new_raw(&root, &icon_path)?;
+    quote!(::std::option::Option::Some(#icon.to_vec()))
   } else {
     quote!(::std::option::Option::None)
   };
@@ -290,8 +292,8 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
   let with_tray_icon_code = if target.is_desktop() {
     if let Some(tray) = &config.app.tray_icon {
       let tray_icon_icon_path = config_parent.join(&tray.icon_path);
-      image_icon(&root, &out_dir, &tray_icon_icon_path, "tray-icon")
-        .map(|i| quote!(context.set_tray_icon(Some(#i));))?
+      let icon = CachedIcon::new(&root, &tray_icon_icon_path)?;
+      quote!(context.set_tray_icon(::std::option::Option::Some(#icon));)
     } else {
       quote!()
     }
@@ -319,8 +321,6 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
       }
     }
 
-    let plist_file = out_dir.join("Info.plist");
-
     let mut plist_contents = std::io::BufWriter::new(Vec::new());
     info_plist
       .to_writer_xml(&mut plist_contents)
@@ -328,12 +328,9 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
     let plist_contents =
       String::from_utf8_lossy(&plist_contents.into_inner().unwrap()).into_owned();
 
-    if plist_contents != std::fs::read_to_string(&plist_file).unwrap_or_default() {
-      std::fs::write(&plist_file, &plist_contents).expect("failed to write Info.plist");
-    }
-
+    let plist = crate::Cached::try_from(plist_contents)?;
     quote!({
-      tauri::embed_plist::embed_info_plist!(concat!(std::env!("OUT_DIR"), "/Info.plist"));
+      tauri::embed_plist::embed_info_plist!(#plist);
     })
   } else {
     quote!(())

+ 2 - 15
core/tauri-codegen/src/embedded_assets.rs

@@ -8,7 +8,6 @@ use quote::{quote, ToTokens, TokenStreamExt};
 use sha2::{Digest, Sha256};
 use std::{
   collections::HashMap,
-  fmt::Write,
   fs::File,
   path::{Path, PathBuf},
 };
@@ -48,7 +47,7 @@ pub enum EmbeddedAssetsError {
   #[error("invalid prefix {prefix} used while including path {path}")]
   PrefixInvalid { prefix: PathBuf, path: PathBuf },
 
-  #[error("invalid extension {extension} used for image {path}, must be `ico` or `png`")]
+  #[error("invalid extension `{extension}` used for image {path}, must be `ico` or `png`")]
   InvalidImageExtension { extension: PathBuf, path: PathBuf },
 
   #[error("failed to walk directory {path} because {error}")]
@@ -341,19 +340,7 @@ impl EmbeddedAssets {
     std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
 
     // get a hash of the input - allows for caching existing files
-    let hash = {
-      let mut hasher = crate::vendor::blake3_reference::Hasher::default();
-      hasher.update(&input);
-
-      let mut bytes = [0u8; 32];
-      hasher.finalize(&mut bytes);
-
-      let mut hex = String::with_capacity(2 * bytes.len());
-      for b in bytes {
-        write!(hex, "{b:02x}").map_err(EmbeddedAssetsError::Hex)?;
-      }
-      hex
-    };
+    let hash = crate::checksum(&input).map_err(EmbeddedAssetsError::Hex)?;
 
     // use the content hash to determine filename, keep extensions that exist
     let out_path = if let Some(ext) = path.extension().and_then(|e| e.to_str()) {

+ 99 - 123
core/tauri-codegen/src/image.rs

@@ -2,141 +2,117 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
-use crate::embedded_assets::{ensure_out_dir, EmbeddedAssetsError, EmbeddedAssetsResult};
-use proc_macro2::{Span, TokenStream};
-use quote::{quote, ToTokens};
-use std::path::Path;
-use syn::{punctuated::Punctuated, Ident, PathArguments, PathSegment, Token};
-
-pub fn include_image_codegen(
-  path: &Path,
-  out_file_name: &str,
-) -> EmbeddedAssetsResult<TokenStream> {
-  let out_dir = ensure_out_dir()?;
-
-  let mut segments = Punctuated::new();
-  segments.push(PathSegment {
-    ident: Ident::new("tauri", Span::call_site()),
-    arguments: PathArguments::None,
-  });
-  let root = syn::Path {
-    leading_colon: Some(Token![::](Span::call_site())),
-    segments,
-  };
-
-  image_icon(&root.to_token_stream(), &out_dir, path, out_file_name)
+use crate::{
+  embedded_assets::{EmbeddedAssetsError, EmbeddedAssetsResult},
+  Cached,
+};
+use proc_macro2::TokenStream;
+use quote::{quote, ToTokens, TokenStreamExt};
+use std::{ffi::OsStr, io::Cursor, path::Path};
+
+/// The format the Icon is consumed as.
+pub(crate) enum IconFormat {
+  /// The image, completely unmodified.
+  Raw,
+
+  /// RGBA raw data, meant to be consumed by [`tauri::image::Image`].
+  Image { width: u32, height: u32 },
 }
 
-pub(crate) fn image_icon(
-  root: &TokenStream,
-  out_dir: &Path,
-  path: &Path,
-  out_file_name: &str,
-) -> EmbeddedAssetsResult<TokenStream> {
-  let extension = path.extension().unwrap_or_default();
-  if extension == "ico" {
-    ico_icon(root, out_dir, path, out_file_name)
-  } else if extension == "png" {
-    png_icon(root, out_dir, path, out_file_name)
-  } else {
-    Err(EmbeddedAssetsError::InvalidImageExtension {
-      extension: extension.into(),
-      path: path.to_path_buf(),
-    })
-  }
+pub struct CachedIcon {
+  cache: Cached,
+  format: IconFormat,
+  root: TokenStream,
 }
 
-pub(crate) fn raw_icon(
-  out_dir: &Path,
-  path: &Path,
-  out_file_name: &str,
-) -> EmbeddedAssetsResult<TokenStream> {
-  let bytes =
-    std::fs::read(path).unwrap_or_else(|e| panic!("failed to read icon {}: {}", path.display(), e));
-
-  let out_path = out_dir.join(out_file_name);
-  write_if_changed(&out_path, &bytes).map_err(|error| EmbeddedAssetsError::AssetWrite {
-    path: path.to_owned(),
-    error,
-  })?;
-
-  let icon = quote!(::std::option::Option::Some(
-    include_bytes!(concat!(std::env!("OUT_DIR"), "/", #out_file_name)).to_vec()
-  ));
-  Ok(icon)
-}
-
-pub(crate) fn ico_icon(
-  root: &TokenStream,
-  out_dir: &Path,
-  path: &Path,
-  out_file_name: &str,
-) -> EmbeddedAssetsResult<TokenStream> {
-  let file = std::fs::File::open(path)
-    .unwrap_or_else(|e| panic!("failed to open icon {}: {}", path.display(), e));
-  let icon_dir = ico::IconDir::read(file)
-    .unwrap_or_else(|e| panic!("failed to parse icon {}: {}", path.display(), e));
-  let entry = &icon_dir.entries()[0];
-  let rgba = entry
-    .decode()
-    .unwrap_or_else(|e| panic!("failed to decode icon {}: {}", path.display(), e))
-    .rgba_data()
-    .to_vec();
-  let width = entry.width();
-  let height = entry.height();
-
-  let out_path = out_dir.join(out_file_name);
-  write_if_changed(&out_path, &rgba).map_err(|error| EmbeddedAssetsError::AssetWrite {
-    path: path.to_owned(),
-    error,
-  })?;
-
-  let icon = quote!(#root::image::Image::new(include_bytes!(concat!(std::env!("OUT_DIR"), "/", #out_file_name)), #width, #height));
-  Ok(icon)
-}
+impl CachedIcon {
+  pub fn new(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
+    match icon.extension().map(OsStr::to_string_lossy).as_deref() {
+      Some("png") => Self::new_png(root, icon),
+      Some("ico") => Self::new_ico(root, icon),
+      unknown => Err(EmbeddedAssetsError::InvalidImageExtension {
+        extension: unknown.unwrap_or_default().into(),
+        path: icon.to_path_buf(),
+      }),
+    }
+  }
 
-pub(crate) fn png_icon(
-  root: &TokenStream,
-  out_dir: &Path,
-  path: &Path,
-  out_file_name: &str,
-) -> EmbeddedAssetsResult<TokenStream> {
-  let file = std::fs::File::open(path)
-    .unwrap_or_else(|e| panic!("failed to open icon {}: {}", path.display(), e));
-  let decoder = png::Decoder::new(file);
-  let mut reader = decoder
-    .read_info()
-    .unwrap_or_else(|e| panic!("failed to read icon {}: {}", path.display(), e));
-
-  let (color_type, _) = reader.output_color_type();
-
-  if color_type != png::ColorType::Rgba {
-    panic!("icon {} is not RGBA", path.display());
+  /// Cache the icon without any manipulation.
+  pub fn new_raw(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
+    let buf = Self::open(icon);
+    Cached::try_from(buf).map(|cache| Self {
+      cache,
+      root: root.clone(),
+      format: IconFormat::Raw,
+    })
   }
 
-  let mut buffer: Vec<u8> = Vec::new();
-  while let Ok(Some(row)) = reader.next_row() {
-    buffer.extend(row.data());
+  /// Cache an ICO icon as RGBA data, see [`ImageFormat::Image`].
+  pub fn new_ico(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
+    let buf = Self::open(icon);
+
+    let icon_dir = ico::IconDir::read(Cursor::new(&buf))
+      .unwrap_or_else(|e| panic!("failed to parse icon {}: {}", icon.display(), e));
+
+    let entry = &icon_dir.entries()[0];
+    let rgba = entry
+      .decode()
+      .unwrap_or_else(|e| panic!("failed to decode icon {}: {}", icon.display(), e))
+      .rgba_data()
+      .to_vec();
+
+    Cached::try_from(rgba).map(|cache| Self {
+      cache,
+      root: root.clone(),
+      format: IconFormat::Image {
+        width: entry.width(),
+        height: entry.height(),
+      },
+    })
   }
-  let width = reader.info().width;
-  let height = reader.info().height;
 
-  let out_path = out_dir.join(out_file_name);
-  write_if_changed(&out_path, &buffer).map_err(|error| EmbeddedAssetsError::AssetWrite {
-    path: path.to_owned(),
-    error,
-  })?;
+  /// Cache a PNG icon as RGBA data, see [`ImageFormat::Image`].
+  pub fn new_png(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
+    let buf = Self::open(icon);
+    let decoder = png::Decoder::new(Cursor::new(&buf));
+    let mut reader = decoder
+      .read_info()
+      .unwrap_or_else(|e| panic!("failed to read icon {}: {}", icon.display(), e));
 
-  let icon = quote!(#root::image::Image::new(include_bytes!(concat!(std::env!("OUT_DIR"), "/", #out_file_name)), #width, #height));
-  Ok(icon)
-}
+    if reader.output_color_type().0 != png::ColorType::Rgba {
+      panic!("icon {} is not RGBA", icon.display());
+    }
 
-fn write_if_changed(out_path: &Path, data: &[u8]) -> std::io::Result<()> {
-  if let Ok(curr) = std::fs::read(out_path) {
-    if curr == data {
-      return Ok(());
+    let mut rgba = Vec::with_capacity(reader.output_buffer_size());
+    while let Ok(Some(row)) = reader.next_row() {
+      rgba.extend(row.data());
     }
+
+    Cached::try_from(rgba).map(|cache| Self {
+      cache,
+      root: root.clone(),
+      format: IconFormat::Image {
+        width: reader.info().width,
+        height: reader.info().height,
+      },
+    })
   }
 
-  std::fs::write(out_path, data)
+  fn open(path: &Path) -> Vec<u8> {
+    std::fs::read(path).unwrap_or_else(|e| panic!("failed to open icon {}: {}", path.display(), e))
+  }
+}
+
+impl ToTokens for CachedIcon {
+  fn to_tokens(&self, tokens: &mut TokenStream) {
+    let root = &self.root;
+    let cache = &self.cache;
+    let raw = quote!(::std::include_bytes!(#cache));
+    tokens.append_all(match self.format {
+      IconFormat::Raw => raw,
+      IconFormat::Image { width, height } => {
+        quote!(#root::image::Image::new(#raw, #width, #height))
+      }
+    })
+  }
 }

+ 57 - 2
core/tauri-codegen/src/lib.rs

@@ -13,17 +13,21 @@
 )]
 
 pub use self::context::{context_codegen, ContextData};
-pub use self::image::include_image_codegen;
+use crate::embedded_assets::{ensure_out_dir, EmbeddedAssetsError};
+use proc_macro2::TokenStream;
+use quote::{quote, ToTokens, TokenStreamExt};
 use std::{
   borrow::Cow,
+  fmt::{self, Write},
   path::{Path, PathBuf},
 };
 pub use tauri_utils::config::{parse::ConfigError, Config};
 use tauri_utils::platform::Target;
+use tauri_utils::write_if_changed;
 
 mod context;
 pub mod embedded_assets;
-mod image;
+pub mod image;
 #[doc(hidden)]
 pub mod vendor;
 
@@ -97,3 +101,54 @@ pub fn get_config(path: &Path) -> Result<(Config, PathBuf), CodegenConfigError>
 
   Ok((config, parent))
 }
+
+/// Create a blake3 checksum of the passed bytes.
+fn checksum(bytes: &[u8]) -> Result<String, fmt::Error> {
+  let mut hasher = vendor::blake3_reference::Hasher::default();
+  hasher.update(bytes);
+
+  let mut bytes = [0u8; 32];
+  hasher.finalize(&mut bytes);
+
+  let mut hex = String::with_capacity(2 * bytes.len());
+  for b in bytes {
+    write!(hex, "{b:02x}")?;
+  }
+  Ok(hex)
+}
+
+/// Cache the data to `$OUT_DIR`, only if it does not already exist.
+///
+/// Due to using a checksum as the filename, an existing file should be the exact same content
+/// as the data being checked.
+struct Cached {
+  checksum: String,
+}
+
+impl TryFrom<String> for Cached {
+  type Error = EmbeddedAssetsError;
+
+  fn try_from(value: String) -> Result<Self, Self::Error> {
+    Self::try_from(Vec::from(value))
+  }
+}
+
+impl TryFrom<Vec<u8>> for Cached {
+  type Error = EmbeddedAssetsError;
+
+  fn try_from(content: Vec<u8>) -> Result<Self, Self::Error> {
+    let checksum = checksum(content.as_ref()).map_err(EmbeddedAssetsError::Hex)?;
+    let path = ensure_out_dir()?.join(&checksum);
+
+    write_if_changed(&path, &content)
+      .map(|_| Self { checksum })
+      .map_err(|error| EmbeddedAssetsError::AssetWrite { path, error })
+  }
+}
+
+impl ToTokens for Cached {
+  fn to_tokens(&self, tokens: &mut TokenStream) {
+    let path = &self.checksum;
+    tokens.append_all(quote!(::std::concat!(::std::env!("OUT_DIR"), "/", #path)))
+  }
+}

+ 7 - 15
core/tauri-config-schema/build.rs

@@ -2,23 +2,15 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
-use std::{
-  error::Error,
-  fs::File,
-  io::{BufWriter, Write},
-  path::PathBuf,
-};
+use std::{error::Error, path::PathBuf};
+use tauri_utils::{config::Config, write_if_changed};
 
 pub fn main() -> Result<(), Box<dyn Error>> {
-  let schema = schemars::schema_for!(tauri_utils::config::Config);
-  let schema_str = serde_json::to_string_pretty(&schema).unwrap();
-  let crate_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
-  for file in [
-    crate_dir.join("schema.json"),
-    crate_dir.join("../../tooling/cli/schema.json"),
-  ] {
-    let mut schema_file = BufWriter::new(File::create(file)?);
-    write!(schema_file, "{schema_str}")?;
+  let schema = schemars::schema_for!(Config);
+  let schema = serde_json::to_string_pretty(&schema)?;
+  let out = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
+  for path in ["schema.json", "../../tooling/cli/schema.json"] {
+    write_if_changed(out.join(path), &schema)?;
   }
 
   Ok(())

+ 5 - 8
core/tauri-macros/src/lib.rs

@@ -15,8 +15,9 @@ use std::path::PathBuf;
 
 use crate::context::ContextItems;
 use proc_macro::TokenStream;
-use quote::quote;
+use quote::{quote, ToTokens};
 use syn::{parse2, parse_macro_input, LitStr};
+use tauri_codegen::image::CachedIcon;
 
 mod command;
 mod menu;
@@ -203,13 +204,9 @@ pub fn include_image(tokens: TokenStream) -> TokenStream {
     );
     return quote!(compile_error!(#error_string)).into();
   }
-  match tauri_codegen::include_image_codegen(
-    &resolved_path,
-    resolved_path.file_name().unwrap().to_str().unwrap(),
-  )
-  .map_err(|error| error.to_string())
-  {
-    Ok(output) => output,
+
+  match CachedIcon::new(&quote!(::tauri), &resolved_path).map_err(|error| error.to_string()) {
+    Ok(icon) => icon.into_token_stream(),
     Err(error) => quote!(compile_error!(#error)),
   }
   .into()

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

@@ -380,3 +380,20 @@ pub fn display_path<P: AsRef<Path>>(p: P) -> String {
     .display()
     .to_string()
 }
+
+/// Write the file only if the content of the existing file (if any) is different.
+///
+/// This will always write unless the file exists with identical content.
+pub fn write_if_changed<P, C>(path: P, content: C) -> std::io::Result<()>
+where
+  P: AsRef<Path>,
+  C: AsRef<[u8]>,
+{
+  if let Ok(existing) = std::fs::read(&path) {
+    if existing == content.as_ref() {
+      return Ok(());
+    }
+  }
+
+  std::fs::write(path, content)
+}