diff --git a/.changes/check-plugin-versions.md b/.changes/check-plugin-versions.md new file mode 100644 index 000000000000..2a87a57d3010 --- /dev/null +++ b/.changes/check-plugin-versions.md @@ -0,0 +1,6 @@ +--- +"tauri-cli": minor:feat +"@tauri-apps/cli": minor:feat +--- + +Check installed plugin NPM/crate versions for incompatible releases. diff --git a/crates/tauri-cli/src/build.rs b/crates/tauri-cli/src/build.rs index 77675e8ae108..bf8f68d7a70a 100644 --- a/crates/tauri-cli/src/build.rs +++ b/crates/tauri-cli/src/build.rs @@ -6,9 +6,10 @@ use crate::{ bundle::BundleFormat, helpers::{ self, - app_paths::tauri_dir, + app_paths::{frontend_dir, tauri_dir}, config::{get as get_config, ConfigHandle, FrontendDist}, }, + info::plugins::check_mismatched_packages, interface::{rust::get_cargo_target_dir, AppInterface, Interface}, ConfigValue, Result, }; @@ -70,6 +71,11 @@ pub struct Options { /// On subsequent runs, it's recommended to disable this setting again. #[clap(long)] pub skip_stapling: bool, + /// Do not error out if a version mismatch is detected on a Tauri package. + /// + /// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior. + #[clap(long)] + pub ignore_version_mismatches: bool, } pub fn command(mut options: Options, verbosity: u8) -> Result<()> { @@ -131,6 +137,18 @@ pub fn setup( mobile: bool, ) -> Result<()> { let tauri_path = tauri_dir(); + + // TODO: Maybe optimize this to run in parallel in the future + // see https://github.com/tauri-apps/tauri/pull/13993#discussion_r2280697117 + log::info!("Looking up installed tauri packages to check mismatched versions..."); + if let Err(error) = check_mismatched_packages(frontend_dir(), tauri_path) { + if options.ignore_version_mismatches { + log::error!("{error}"); + } else { + return Err(error); + } + } + set_current_dir(tauri_path).with_context(|| "failed to change current working directory")?; let config_guard = config.lock().unwrap(); @@ -141,11 +159,9 @@ pub fn setup( .unwrap_or_else(|| "tauri.conf.json".into()); if config_.identifier == "com.tauri.dev" { - log::error!( - "You must change the bundle identifier in `{} identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.", - bundle_identifier_source + anyhow::bail!( + "You must change the bundle identifier in `{bundle_identifier_source} identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.", ); - std::process::exit(1); } if config_ @@ -153,12 +169,11 @@ pub fn setup( .chars() .any(|ch| !(ch.is_alphanumeric() || ch == '-' || ch == '.')) { - log::error!( + anyhow::bail!( "The bundle identifier \"{}\" set in `{} identifier`. The bundle identifier string must contain only alphanumeric characters (A-Z, a-z, and 0-9), hyphens (-), and periods (.).", config_.identifier, bundle_identifier_source ); - std::process::exit(1); } if config_.identifier.ends_with(".app") { diff --git a/crates/tauri-cli/src/dev.rs b/crates/tauri-cli/src/dev.rs index 05b4dee137b1..5fabb4a17339 100644 --- a/crates/tauri-cli/src/dev.rs +++ b/crates/tauri-cli/src/dev.rs @@ -10,6 +10,7 @@ use crate::{ get as get_config, reload as reload_config, BeforeDevCommand, ConfigHandle, FrontendDist, }, }, + info::plugins::check_mismatched_packages, interface::{AppInterface, ExitReason, Interface}, CommandExt, ConfigValue, Result, }; @@ -135,6 +136,13 @@ fn command_internal(mut options: Options) -> Result<()> { pub fn setup(interface: &AppInterface, options: &mut Options, config: ConfigHandle) -> Result<()> { let tauri_path = tauri_dir(); + + std::thread::spawn(|| { + if let Err(error) = check_mismatched_packages(frontend_dir(), tauri_path) { + log::error!("{error}"); + } + }); + set_current_dir(tauri_path).with_context(|| "failed to change current working directory")?; if let Some(before_dev) = config diff --git a/crates/tauri-cli/src/helpers/cargo_manifest.rs b/crates/tauri-cli/src/helpers/cargo_manifest.rs index e043318a3ca4..8ab152847b19 100644 --- a/crates/tauri-cli/src/helpers/cargo_manifest.rs +++ b/crates/tauri-cli/src/helpers/cargo_manifest.rs @@ -10,6 +10,8 @@ use std::{ path::{Path, PathBuf}, }; +use crate::interface::rust::get_workspace_dir; + #[derive(Clone, Deserialize)] pub struct CargoLockPackage { pub name: String, @@ -49,6 +51,18 @@ pub struct CargoManifest { pub dependencies: HashMap, } +pub fn cargo_manifest_and_lock(tauri_dir: &Path) -> (Option, Option) { + let manifest: Option = fs::read_to_string(tauri_dir.join("Cargo.toml")) + .ok() + .and_then(|manifest_contents| toml::from_str(&manifest_contents).ok()); + + let lock: Option = get_workspace_dir() + .ok() + .and_then(|p| fs::read_to_string(p.join("Cargo.lock")).ok()) + .and_then(|s| toml::from_str(&s).ok()); + + (manifest, lock) +} #[derive(Default)] pub struct CrateVersion { pub version: Option, diff --git a/crates/tauri-cli/src/helpers/npm.rs b/crates/tauri-cli/src/helpers/npm.rs index 1e6acdb9a55c..fe162cdb364e 100644 --- a/crates/tauri-cli/src/helpers/npm.rs +++ b/crates/tauri-cli/src/helpers/npm.rs @@ -3,9 +3,10 @@ // SPDX-License-Identifier: MIT use anyhow::Context; +use serde::Deserialize; use crate::helpers::cross_command; -use std::{fmt::Display, path::Path, process::Command}; +use std::{collections::HashMap, fmt::Display, path::Path, process::Command}; pub fn manager_version(package_manager: &str) -> Option { cross_command(package_manager) @@ -197,6 +198,7 @@ impl PackageManager { Ok(()) } + // TODO: Use `current_package_versions` as much as possible for better speed pub fn current_package_version>( &self, name: &str, @@ -254,4 +256,157 @@ impl PackageManager { Ok(None) } } + + pub fn current_package_versions( + &self, + packages: &[String], + frontend_dir: &Path, + ) -> crate::Result> { + let output = match self { + PackageManager::Yarn => return yarn_package_versions(packages, frontend_dir), + PackageManager::YarnBerry => return yarn_berry_package_versions(packages, frontend_dir), + PackageManager::Pnpm => cross_command("pnpm") + .arg("list") + .args(packages) + .args(["--json", "--depth", "0"]) + .current_dir(frontend_dir) + .output()?, + // Bun and Deno don't support `list` command + PackageManager::Npm | PackageManager::Bun | PackageManager::Deno => cross_command("npm") + .arg("list") + .args(packages) + .args(["--json", "--depth", "0"]) + .current_dir(frontend_dir) + .output()?, + }; + + let mut versions = HashMap::new(); + let stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() { + return Ok(versions); + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct ListOutput { + #[serde(default)] + dependencies: HashMap, + #[serde(default)] + dev_dependencies: HashMap, + } + + #[derive(Deserialize)] + struct ListDependency { + version: String, + } + + let json: ListOutput = serde_json::from_str(&stdout)?; + for (package, dependency) in json.dependencies.into_iter().chain(json.dev_dependencies) { + let version = dependency.version; + if let Ok(version) = semver::Version::parse(&version) { + versions.insert(package, version); + } else { + log::error!("Failed to parse version `{version}` for NPM package `{package}`"); + } + } + Ok(versions) + } +} + +fn yarn_package_versions( + packages: &[String], + frontend_dir: &Path, +) -> crate::Result> { + let output = cross_command("yarn") + .arg("list") + .args(packages) + .args(["--json", "--depth", "0"]) + .current_dir(frontend_dir) + .output()?; + + let mut versions = HashMap::new(); + let stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() { + return Ok(versions); + } + + #[derive(Deserialize)] + struct YarnListOutput { + data: YarnListOutputData, + } + + #[derive(Deserialize)] + struct YarnListOutputData { + trees: Vec, + } + + #[derive(Deserialize)] + struct YarnListOutputDataTree { + name: String, + } + + for line in stdout.lines() { + if let Ok(tree) = serde_json::from_str::(line) { + for tree in tree.data.trees { + let Some((name, version)) = tree.name.rsplit_once('@') else { + continue; + }; + if let Ok(version) = semver::Version::parse(version) { + versions.insert(name.to_owned(), version); + } else { + log::error!("Failed to parse version `{version}` for NPM package `{name}`"); + } + } + return Ok(versions); + } + } + + Ok(versions) +} + +fn yarn_berry_package_versions( + packages: &[String], + frontend_dir: &Path, +) -> crate::Result> { + let output = cross_command("yarn") + .args(["info", "--json"]) + .current_dir(frontend_dir) + .output()?; + + let mut versions = HashMap::new(); + let stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() { + return Ok(versions); + } + + #[derive(Deserialize)] + struct YarnBerryInfoOutput { + value: String, + children: YarnBerryInfoOutputChildren, + } + + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + struct YarnBerryInfoOutputChildren { + version: String, + } + + for line in stdout.lines() { + if let Ok(info) = serde_json::from_str::(line) { + let Some((name, _)) = info.value.rsplit_once('@') else { + continue; + }; + if !packages.iter().any(|package| package == name) { + continue; + } + let version = info.children.version; + if let Ok(version) = semver::Version::parse(&version) { + versions.insert(name.to_owned(), version); + } else { + log::error!("Failed to parse version `{version}` for NPM package `{name}`"); + } + } + } + + Ok(versions) } diff --git a/crates/tauri-cli/src/info/mod.rs b/crates/tauri-cli/src/info/mod.rs index fc77529a596a..1084ba4e3a24 100644 --- a/crates/tauri-cli/src/info/mod.rs +++ b/crates/tauri-cli/src/info/mod.rs @@ -20,7 +20,7 @@ mod env_system; mod ios; mod packages_nodejs; mod packages_rust; -mod plugins; +pub mod plugins; #[derive(Deserialize)] struct JsCliVersionMetadata { diff --git a/crates/tauri-cli/src/info/packages_rust.rs b/crates/tauri-cli/src/info/packages_rust.rs index 4db49b0c1061..c9b9c343db3c 100644 --- a/crates/tauri-cli/src/info/packages_rust.rs +++ b/crates/tauri-cli/src/info/packages_rust.rs @@ -3,14 +3,10 @@ // SPDX-License-Identifier: MIT use super::{ActionResult, SectionItem}; -use crate::{ - helpers::cargo_manifest::{ - crate_latest_version, crate_version, CargoLock, CargoManifest, CrateVersion, - }, - interface::rust::get_workspace_dir, +use crate::helpers::cargo_manifest::{ + cargo_manifest_and_lock, crate_latest_version, crate_version, CrateVersion, }; use colored::Colorize; -use std::fs::read_to_string; use std::path::{Path, PathBuf}; pub fn items(frontend_dir: Option<&PathBuf>, tauri_dir: Option<&Path>) -> Vec { @@ -18,17 +14,7 @@ pub fn items(frontend_dir: Option<&PathBuf>, tauri_dir: Option<&Path>) -> Vec = - if let Ok(manifest_contents) = read_to_string(tauri_dir.join("Cargo.toml")) { - toml::from_str(&manifest_contents).ok() - } else { - None - }; - let lock: Option = get_workspace_dir() - .ok() - .and_then(|p| read_to_string(p.join("Cargo.lock")).ok()) - .and_then(|s| toml::from_str(&s).ok()); - + let (manifest, lock) = cargo_manifest_and_lock(tauri_dir); for dep in ["tauri", "tauri-build", "wry", "tao"] { let crate_version = crate_version(tauri_dir, manifest.as_ref(), lock.as_ref(), dep); let item = rust_section_item(dep, crate_version); diff --git a/crates/tauri-cli/src/info/plugins.rs b/crates/tauri-cli/src/info/plugins.rs index cc7cd7d0a3c6..3169324e23cb 100644 --- a/crates/tauri-cli/src/info/plugins.rs +++ b/crates/tauri-cli/src/info/plugins.rs @@ -3,20 +3,101 @@ // SPDX-License-Identifier: MIT use std::{ - fs, + collections::HashMap, + iter, path::{Path, PathBuf}, }; -use crate::{ - helpers::{ - self, - cargo_manifest::{crate_version, CargoLock, CargoManifest}, - npm::PackageManager, - }, - interface::rust::get_workspace_dir, +use crate::helpers::{ + self, + cargo_manifest::{cargo_manifest_and_lock, crate_version}, + npm::PackageManager, }; use super::{packages_nodejs, packages_rust, SectionItem}; +use anyhow::anyhow; + +#[derive(Debug)] +pub struct InstalledPackage { + pub crate_name: String, + pub npm_name: String, + pub crate_version: semver::Version, + pub npm_version: semver::Version, +} + +#[derive(Debug)] +pub struct InstalledPackages(Vec); + +impl InstalledPackages { + pub fn mismatched(&self) -> Vec<&InstalledPackage> { + self + .0 + .iter() + .filter(|p| { + p.crate_version.major != p.npm_version.major || p.crate_version.minor != p.npm_version.minor + }) + .collect() + } +} + +pub fn installed_tauri_packages( + frontend_dir: &Path, + tauri_dir: &Path, + package_manager: PackageManager, +) -> InstalledPackages { + let know_plugins = helpers::plugins::known_plugins(); + let crate_names: Vec = iter::once("tauri".to_owned()) + .chain( + know_plugins + .keys() + .map(|plugin_name| format!("tauri-plugin-{plugin_name}")), + ) + .collect(); + let npm_names: Vec = iter::once("@tauri-apps/api".to_owned()) + .chain( + know_plugins + .keys() + .map(|plugin_name| format!("@tauri-apps/plugin-{plugin_name}")), + ) + .collect(); + + let (manifest, lock) = cargo_manifest_and_lock(tauri_dir); + + let mut rust_plugins: HashMap = crate_names + .iter() + .filter_map(|crate_name| { + let crate_version = + crate_version(tauri_dir, manifest.as_ref(), lock.as_ref(), crate_name).version?; + let crate_version = semver::Version::parse(&crate_version) + .inspect_err(|_| { + log::error!("Failed to parse version `{crate_version}` for crate `{crate_name}`"); + }) + .ok()?; + Some((crate_name.clone(), crate_version)) + }) + .collect(); + + let mut npm_plugins = package_manager + .current_package_versions(&npm_names, frontend_dir) + .unwrap_or_default(); + + let installed_plugins = crate_names + .iter() + .zip(npm_names.iter()) + .filter_map(|(crate_name, npm_name)| { + let (crate_name, crate_version) = rust_plugins.remove_entry(crate_name)?; + let (npm_name, npm_version) = npm_plugins.remove_entry(npm_name)?; + Some(InstalledPackage { + npm_name, + npm_version, + crate_name, + crate_version, + }) + }) + .collect(); + + InstalledPackages(installed_plugins) +} pub fn items( frontend_dir: Option<&PathBuf>, @@ -27,17 +108,7 @@ pub fn items( if tauri_dir.is_some() || frontend_dir.is_some() { if let Some(tauri_dir) = tauri_dir { - let manifest: Option = - if let Ok(manifest_contents) = fs::read_to_string(tauri_dir.join("Cargo.toml")) { - toml::from_str(&manifest_contents).ok() - } else { - None - }; - - let lock: Option = get_workspace_dir() - .ok() - .and_then(|p| fs::read_to_string(p.join("Cargo.lock")).ok()) - .and_then(|s| toml::from_str(&s).ok()); + let (manifest, lock) = cargo_manifest_and_lock(tauri_dir); for p in helpers::plugins::known_plugins().keys() { let dep = format!("tauri-plugin-{p}"); @@ -67,3 +138,28 @@ pub fn items( items } + +pub fn check_mismatched_packages(frontend_dir: &Path, tauri_path: &Path) -> crate::Result<()> { + let installed_packages = installed_tauri_packages( + frontend_dir, + tauri_path, + PackageManager::from_project(frontend_dir), + ); + let mismatched_packages = installed_packages.mismatched(); + if mismatched_packages.is_empty() { + return Ok(()); + } + let mismatched_text = mismatched_packages + .iter() + .map( + |InstalledPackage { + crate_name, + crate_version, + npm_name, + npm_version, + }| format!("{crate_name} (v{crate_version}) : {npm_name} (v{npm_version})"), + ) + .collect::>() + .join("\n"); + Err(anyhow!("Found version mismatched Tauri packages. Make sure the NPM and crate versions are on the same major/minor releases:\n{mismatched_text}")) +} diff --git a/crates/tauri-cli/src/mobile/android/build.rs b/crates/tauri-cli/src/mobile/android/build.rs index 4218a76a1320..16a614cf2d50 100644 --- a/crates/tauri-cli/src/mobile/android/build.rs +++ b/crates/tauri-cli/src/mobile/android/build.rs @@ -78,6 +78,11 @@ pub struct Options { /// e.g. `tauri android build -- [runnerArgs]`. #[clap(last(true))] pub args: Vec, + /// Do not error out if a version mismatch is detected on a Tauri package. + /// + /// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior. + #[clap(long)] + pub ignore_version_mismatches: bool, } impl From for BuildOptions { @@ -93,6 +98,7 @@ impl From for BuildOptions { args: options.args, ci: options.ci, skip_stapling: false, + ignore_version_mismatches: options.ignore_version_mismatches, } } } diff --git a/crates/tauri-cli/src/mobile/ios/build.rs b/crates/tauri-cli/src/mobile/ios/build.rs index c5a1abd8eb24..177155dc2168 100644 --- a/crates/tauri-cli/src/mobile/ios/build.rs +++ b/crates/tauri-cli/src/mobile/ios/build.rs @@ -88,6 +88,11 @@ pub struct Options { /// e.g. `tauri ios build -- [runnerArgs]`. #[clap(last(true))] pub args: Vec, + /// Do not error out if a version mismatch is detected on a Tauri package. + /// + /// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior. + #[clap(long)] + pub ignore_version_mismatches: bool, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -133,6 +138,7 @@ impl From for BuildOptions { args: options.args, ci: options.ci, skip_stapling: false, + ignore_version_mismatches: options.ignore_version_mismatches, } } }