feat(cli): check plugin versions for incompatibilities (#13993)

* feat(cli): check plugin versions for incompatibilities

check core plugin versions for incompatibilities between Cargo and NPM releases

a plugin NPM/cargo version is considered "incompatible" if their major or minor versions are not equal

on dev we show an warning
on build we error out (with a `--ignore-incompatible-plugins` flag to prevent that)

this is an idea from @oscartbeaumont
we've seen several plugin changes that require updates for both the cargo and the NPM releases of a plugin, and if they are not in sync, the functionality does not work
e.g. https://github.com/tauri-apps/plugins-workspace/pull/2573 where the change actually breaks the app updater if you miss the NPM update

* Use list to get multiple package versions at once

* Fix for older rust versions

* Clippy

* Support yarn classic

* Support yarn berry

* Use `.cmd` only for `npm`, `yarn`, `pnpm`

* Use yarn list without --pattern

* rename

* Extract function `check_incompatible_packages`

* Check `tauri` <-> `@tauri-apps/api`

* incompatible -> mismatched

* run build check in parallel

* rename struct

* Switch back to use sync check and add todo

* Extract to function `cargo_manifest_and_lock`

---------

Co-authored-by: Tony <legendmastertony@gmail.com>
This commit is contained in:
Lucas Fernandes Nogueira 2025-08-17 12:24:40 -03:00 committed by GitHub
parent 7c2eb31c83
commit bc4afe7dd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 337 additions and 45 deletions

View File

@ -0,0 +1,6 @@
---
"tauri-cli": minor:feat
"@tauri-apps/cli": minor:feat
---
Check installed plugin NPM/crate versions for incompatible releases.

View File

@ -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") {

View File

@ -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

View File

@ -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<String, CargoManifestDependency>,
}
pub fn cargo_manifest_and_lock(tauri_dir: &Path) -> (Option<CargoManifest>, Option<CargoLock>) {
let manifest: Option<CargoManifest> = fs::read_to_string(tauri_dir.join("Cargo.toml"))
.ok()
.and_then(|manifest_contents| toml::from_str(&manifest_contents).ok());
let lock: Option<CargoLock> = 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<String>,

View File

@ -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<String> {
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<P: AsRef<Path>>(
&self,
name: &str,
@ -254,4 +256,157 @@ impl PackageManager {
Ok(None)
}
}
pub fn current_package_versions(
&self,
packages: &[String],
frontend_dir: &Path,
) -> crate::Result<HashMap<String, semver::Version>> {
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<String, ListDependency>,
#[serde(default)]
dev_dependencies: HashMap<String, ListDependency>,
}
#[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<HashMap<String, semver::Version>> {
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<YarnListOutputDataTree>,
}
#[derive(Deserialize)]
struct YarnListOutputDataTree {
name: String,
}
for line in stdout.lines() {
if let Ok(tree) = serde_json::from_str::<YarnListOutput>(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<HashMap<String, semver::Version>> {
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::<YarnBerryInfoOutput>(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)
}

View File

@ -20,7 +20,7 @@ mod env_system;
mod ios;
mod packages_nodejs;
mod packages_rust;
mod plugins;
pub mod plugins;
#[derive(Deserialize)]
struct JsCliVersionMetadata {

View File

@ -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<SectionItem> {
@ -18,17 +14,7 @@ pub fn items(frontend_dir: Option<&PathBuf>, tauri_dir: Option<&Path>) -> Vec<Se
if tauri_dir.is_some() || frontend_dir.is_some() {
if let Some(tauri_dir) = tauri_dir {
let manifest: Option<CargoManifest> =
if let Ok(manifest_contents) = read_to_string(tauri_dir.join("Cargo.toml")) {
toml::from_str(&manifest_contents).ok()
} else {
None
};
let lock: Option<CargoLock> = 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);

View File

@ -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<InstalledPackage>);
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<String> = iter::once("tauri".to_owned())
.chain(
know_plugins
.keys()
.map(|plugin_name| format!("tauri-plugin-{plugin_name}")),
)
.collect();
let npm_names: Vec<String> = 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<String, semver::Version> = 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<CargoManifest> =
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<CargoLock> = 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::<Vec<_>>()
.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}"))
}

View File

@ -78,6 +78,11 @@ pub struct Options {
/// e.g. `tauri android build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// 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<Options> for BuildOptions {
@ -93,6 +98,7 @@ impl From<Options> for BuildOptions {
args: options.args,
ci: options.ci,
skip_stapling: false,
ignore_version_mismatches: options.ignore_version_mismatches,
}
}
}

View File

@ -88,6 +88,11 @@ pub struct Options {
/// e.g. `tauri ios build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// 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<Options> for BuildOptions {
args: options.args,
ci: options.ci,
skip_stapling: false,
ignore_version_mismatches: options.ignore_version_mismatches,
}
}
}