Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/check-plugin-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-cli": minor:feat
"@tauri-apps/cli": minor:feat
---

Check installed plugin NPM/crate versions for incompatible releases.
34 changes: 33 additions & 1 deletion crates/tauri-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ use crate::{
bundle::BundleFormat,
helpers::{
self,
app_paths::tauri_dir,
app_paths::{frontend_dir, tauri_dir},
config::{get as get_config, ConfigHandle, FrontendDist},
npm::PackageManager,
},
interface::{rust::get_cargo_target_dir, AppInterface, Interface},
ConfigValue, Result,
Expand Down Expand Up @@ -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 aversion incompatibility is detected on an installed plugin.
///
/// Only use this when you are sure the mismatch is incorrectly detected as incompatible plugin versions can lead to unknown behavior.
#[clap(long)]
pub ignore_incompatible_plugins: bool,
}

pub fn command(mut options: Options, verbosity: u8) -> Result<()> {
Expand Down Expand Up @@ -131,6 +137,32 @@ pub fn setup(
mobile: bool,
) -> Result<()> {
let tauri_path = tauri_dir();

log::info!("Looking up installed plugins to check incompatible versions...");
let installed_plugins = crate::info::plugins::installed_plugins(
frontend_dir(),
tauri_path,
PackageManager::from_project(frontend_dir()),
);
let incompatible_plugins = installed_plugins.incompatible();
if !incompatible_plugins.is_empty() {
let incompatible_text = incompatible_plugins
.iter()
.map(|p| {
format!(
"{} (v{}) : {} (v{})",
p.crate_name, p.crate_version, p.npm_name, p.npm_version
)
})
.collect::<Vec<_>>()
.join("\n");
if options.ignore_incompatible_plugins {
log::error!("Found incompatible Tauri plugins. Make sure the NPM and crate versions are on the same major/minor releases:\n{}", incompatible_text);
} else {
anyhow::bail!("Found incompatible Tauri plugins. Make sure the NPM and crate versions are on the same major/minor releases:\n{}", incompatible_text);
}
}

set_current_dir(tauri_path).with_context(|| "failed to change current working directory")?;

let config_guard = config.lock().unwrap();
Expand Down
24 changes: 24 additions & 0 deletions crates/tauri-cli/src/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
config::{
get as get_config, reload as reload_config, BeforeDevCommand, ConfigHandle, FrontendDist,
},
npm::PackageManager,
},
interface::{AppInterface, ExitReason, Interface},
CommandExt, ConfigValue, Result,
Expand Down Expand Up @@ -135,6 +136,29 @@ 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(|| {
let installed_plugins = crate::info::plugins::installed_plugins(
frontend_dir(),
tauri_path,
PackageManager::from_project(frontend_dir()),
);
let incompatible_plugins = installed_plugins.incompatible();
if !incompatible_plugins.is_empty() {
let incompatible_text = incompatible_plugins
.iter()
.map(|p| {
format!(
"{} (v{}) : {} (v{})",
p.crate_name, p.crate_version, p.npm_name, p.npm_version
)
})
.collect::<Vec<_>>()
.join("\n");
log::warn!("Found incompatible Tauri plugins. Make sure the NPM and crate versions are on the same major/minor releases:\n{}", incompatible_text);
}
});

set_current_dir(tauri_path).with_context(|| "failed to change current working directory")?;

if let Some(before_dev) = config
Expand Down
156 changes: 155 additions & 1 deletion crates/tauri-cli/src/helpers/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -254,4 +255,157 @@ impl PackageManager {
Ok(None)
}
}

pub fn current_package_versions(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably want to migrate the other ones to use this one as much as possible to speed things up, could push that to the future though

&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)
}
2 changes: 1 addition & 1 deletion crates/tauri-cli/src/info/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ mod env_system;
mod ios;
mod packages_nodejs;
mod packages_rust;
mod plugins;
pub mod plugins;

#[derive(Deserialize)]
struct JsCliVersionMetadata {
Expand Down
87 changes: 87 additions & 0 deletions crates/tauri-cli/src/info/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT

use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
Expand All @@ -18,6 +19,92 @@ use crate::{

use super::{packages_nodejs, packages_rust, SectionItem};

#[derive(Debug)]
pub struct InstalledPlugin {
pub crate_name: String,
pub npm_name: String,
pub crate_version: semver::Version,
pub npm_version: semver::Version,
}

#[derive(Debug)]
pub struct InstalledPlugins(Vec<InstalledPlugin>);

impl InstalledPlugins {
pub fn incompatible(&self) -> Vec<&InstalledPlugin> {
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_plugins(
frontend_dir: &Path,
tauri_dir: &Path,
package_manager: PackageManager,
) -> InstalledPlugins {
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 know_plugins = helpers::plugins::known_plugins();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to also include @tauri-app/api here? And maybe also @tauri-app/cli

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well we do not have a good way to match those..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'd need to agree on whether to match the tauri package versions and which packages and patch or minor and then actually do that 🙃

sooner or later we'll probably have to tackle this.

Copy link
Contributor

@Legend-Master Legend-Master Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tauri-app/api and tauri will need to match in minor versions at least

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except that we failed with that multiple times (especially in v1 at the end). again, i'm not against it but if we add the check here we must start to take this serious.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think before we actually run the check, we should sync our versions for a while, otherwise we suddenly break most builds

let's sync tauri, api and cli
and maybe pin minors for inner dependencies too

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could at least add a check that checks the version of tauri and @tauri-app/api here?

let crate_names: Vec<String> = know_plugins
.keys()
.map(|plugin_name| format!("tauri-plugin-{plugin_name}"))
.collect();
let npm_names: Vec<String> = know_plugins
.keys()
.map(|plugin_name| format!("@tauri-apps/plugin-{plugin_name}"))
.collect();

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(InstalledPlugin {
npm_name,
npm_version,
crate_name,
crate_version,
})
})
.collect();

InstalledPlugins(installed_plugins)
}

pub fn items(
frontend_dir: Option<&PathBuf>,
tauri_dir: Option<&Path>,
Expand Down
Loading
Loading