diff --git a/.changes/runner-object-config.md b/.changes/runner-object-config.md new file mode 100644 index 000000000..60cf4aeab --- /dev/null +++ b/.changes/runner-object-config.md @@ -0,0 +1,6 @@ +--- +'tauri-cli': 'minor:feat' +'tauri-utils': 'minor:feat' +--- + +Allow runner configuration to be an object with cmd, cwd, and args properties. The runner can now be configured as `{ "cmd": "my_runner", "cwd": "/path", "args": ["--quiet"] }` while maintaining backwards compatibility with the existing string format. diff --git a/crates/tauri-cli/config.schema.json b/crates/tauri-cli/config.schema.json index 0d46677dd..e1868bb1c 100644 --- a/crates/tauri-cli/config.schema.json +++ b/crates/tauri-cli/config.schema.json @@ -1805,9 +1805,13 @@ "properties": { "runner": { "description": "The binary used to build and run the application.", - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/RunnerConfig" + }, + { + "type": "null" + } ] }, "devUrl": { @@ -1880,6 +1884,45 @@ }, "additionalProperties": false }, + "RunnerConfig": { + "description": "The runner configuration.", + "anyOf": [ + { + "description": "A string specifying the binary to run.", + "type": "string" + }, + { + "description": "An object with advanced configuration options.", + "type": "object", + "required": [ + "cmd" + ], + "properties": { + "cmd": { + "description": "The binary to run.", + "type": "string" + }, + "cwd": { + "description": "The current working directory to run the command from.", + "type": [ + "string", + "null" + ] + }, + "args": { + "description": "Arguments to pass to the command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + } + ] + }, "FrontendDist": { "description": "Defines the URL or assets to embed in the application.", "anyOf": [ diff --git a/crates/tauri-cli/src/build.rs b/crates/tauri-cli/src/build.rs index 443765e76..f31ab3b94 100644 --- a/crates/tauri-cli/src/build.rs +++ b/crates/tauri-cli/src/build.rs @@ -15,6 +15,7 @@ use crate::{ use anyhow::Context; use clap::{ArgAction, Parser}; use std::env::set_current_dir; +use tauri_utils::config::RunnerConfig; use tauri_utils::platform::Target; #[derive(Debug, Clone, Parser)] @@ -25,7 +26,7 @@ use tauri_utils::platform::Target; pub struct Options { /// Binary to use to build the application, defaults to `cargo` #[clap(short, long)] - pub runner: Option, + pub runner: Option, /// Builds with the debug flag #[clap(short, long)] pub debug: bool, @@ -210,7 +211,7 @@ pub fn setup( } if options.runner.is_none() { - options.runner.clone_from(&config_.build.runner); + options.runner = config_.build.runner.clone(); } options diff --git a/crates/tauri-cli/src/dev.rs b/crates/tauri-cli/src/dev.rs index 3d7ea098e..f802d4480 100644 --- a/crates/tauri-cli/src/dev.rs +++ b/crates/tauri-cli/src/dev.rs @@ -17,7 +17,7 @@ use crate::{ use anyhow::{bail, Context}; use clap::{ArgAction, Parser}; use shared_child::SharedChild; -use tauri_utils::platform::Target; +use tauri_utils::{config::RunnerConfig, platform::Target}; use std::{ env::set_current_dir, @@ -49,7 +49,7 @@ pub const TAURI_CLI_BUILTIN_WATCHER_IGNORE_FILE: &[u8] = pub struct Options { /// Binary to use to run the application #[clap(short, long)] - pub runner: Option, + pub runner: Option, /// Target triple to build against #[clap(short, long)] pub target: Option, @@ -224,9 +224,14 @@ pub fn setup(interface: &AppInterface, options: &mut Options, config: ConfigHand } if options.runner.is_none() { - options + options.runner = config + .lock() + .unwrap() + .as_ref() + .unwrap() + .build .runner - .clone_from(&config.lock().unwrap().as_ref().unwrap().build.runner); + .clone(); } let mut cargo_features = config diff --git a/crates/tauri-cli/src/interface/rust.rs b/crates/tauri-cli/src/interface/rust.rs index 2b046273e..b8990fb45 100644 --- a/crates/tauri-cli/src/interface/rust.rs +++ b/crates/tauri-cli/src/interface/rust.rs @@ -25,7 +25,7 @@ use tauri_bundler::{ IosSettings, MacOsSettings, PackageSettings, Position, RpmSettings, Size, UpdaterSettings, WindowsSettings, }; -use tauri_utils::config::{parse::is_configuration_file, DeepLinkProtocol, Updater}; +use tauri_utils::config::{parse::is_configuration_file, DeepLinkProtocol, RunnerConfig, Updater}; use super::{AppSettings, DevProcess, ExitReason, Interface}; use crate::{ @@ -47,7 +47,7 @@ use manifest::{rewrite_manifest, Manifest}; #[derive(Debug, Default, Clone)] pub struct Options { - pub runner: Option, + pub runner: Option, pub debug: bool, pub target: Option, pub features: Option>, diff --git a/crates/tauri-cli/src/interface/rust/desktop.rs b/crates/tauri-cli/src/interface/rust/desktop.rs index 41836be84..667f9cbbb 100644 --- a/crates/tauri-cli/src/interface/rust/desktop.rs +++ b/crates/tauri-cli/src/interface/rust/desktop.rs @@ -230,11 +230,21 @@ fn cargo_command( available_targets: &mut Option>, config_features: Vec, ) -> crate::Result { - let runner = options.runner.unwrap_or_else(|| "cargo".into()); + let runner_config = options.runner.unwrap_or_else(|| "cargo".into()); - let mut build_cmd = Command::new(runner); + let mut build_cmd = Command::new(runner_config.cmd()); build_cmd.arg(if dev { "run" } else { "build" }); + // Set working directory if specified + if let Some(cwd) = runner_config.cwd() { + build_cmd.current_dir(cwd); + } + + // Add runner-specific arguments first + if let Some(runner_args) = runner_config.args() { + build_cmd.args(runner_args); + } + if let Some(target) = &options.target { if available_targets.is_none() { *available_targets = fetch_available_targets(); diff --git a/crates/tauri-schema-generator/schemas/config.schema.json b/crates/tauri-schema-generator/schemas/config.schema.json index 0d46677dd..e1868bb1c 100644 --- a/crates/tauri-schema-generator/schemas/config.schema.json +++ b/crates/tauri-schema-generator/schemas/config.schema.json @@ -1805,9 +1805,13 @@ "properties": { "runner": { "description": "The binary used to build and run the application.", - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/RunnerConfig" + }, + { + "type": "null" + } ] }, "devUrl": { @@ -1880,6 +1884,45 @@ }, "additionalProperties": false }, + "RunnerConfig": { + "description": "The runner configuration.", + "anyOf": [ + { + "description": "A string specifying the binary to run.", + "type": "string" + }, + { + "description": "An object with advanced configuration options.", + "type": "object", + "required": [ + "cmd" + ], + "properties": { + "cmd": { + "description": "The binary to run.", + "type": "string" + }, + "cwd": { + "description": "The current working directory to run the command from.", + "type": [ + "string", + "null" + ] + }, + "args": { + "description": "Arguments to pass to the command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + } + ] + }, "FrontendDist": { "description": "Defines the URL or assets to embed in the application.", "anyOf": [ diff --git a/crates/tauri-utils/src/config.rs b/crates/tauri-utils/src/config.rs index 29db41d89..8ca8ff86b 100644 --- a/crates/tauri-utils/src/config.rs +++ b/crates/tauri-utils/src/config.rs @@ -2761,6 +2761,77 @@ pub enum HookCommand { }, } +/// The runner configuration. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(untagged)] +pub enum RunnerConfig { + /// A string specifying the binary to run. + String(String), + /// An object with advanced configuration options. + Object { + /// The binary to run. + cmd: String, + /// The current working directory to run the command from. + cwd: Option, + /// Arguments to pass to the command. + args: Option>, + }, +} + +impl Default for RunnerConfig { + fn default() -> Self { + RunnerConfig::String("cargo".to_string()) + } +} + +impl RunnerConfig { + /// Returns the command to run. + pub fn cmd(&self) -> &str { + match self { + RunnerConfig::String(cmd) => cmd, + RunnerConfig::Object { cmd, .. } => cmd, + } + } + + /// Returns the working directory. + pub fn cwd(&self) -> Option<&str> { + match self { + RunnerConfig::String(_) => None, + RunnerConfig::Object { cwd, .. } => cwd.as_deref(), + } + } + + /// Returns the arguments. + pub fn args(&self) -> Option<&[String]> { + match self { + RunnerConfig::String(_) => None, + RunnerConfig::Object { args, .. } => args.as_deref(), + } + } +} + +impl std::str::FromStr for RunnerConfig { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(RunnerConfig::String(s.to_string())) + } +} + +impl From<&str> for RunnerConfig { + fn from(s: &str) -> Self { + RunnerConfig::String(s.to_string()) + } +} + +impl From for RunnerConfig { + fn from(s: String) -> Self { + RunnerConfig::String(s) + } +} + /// The Build configuration object. /// /// See more: @@ -2770,7 +2841,7 @@ pub enum HookCommand { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct BuildConfig { /// The binary used to build and run the application. - pub runner: Option, + pub runner: Option, /// The URL to load in development. /// /// This is usually an URL to a dev server, which serves your application assets with hot-reload and HMR. @@ -3436,11 +3507,34 @@ mod build { } } + impl ToTokens for RunnerConfig { + fn to_tokens(&self, tokens: &mut TokenStream) { + let prefix = quote! { ::tauri::utils::config::RunnerConfig }; + + tokens.append_all(match self { + Self::String(cmd) => { + let cmd = cmd.as_str(); + quote!(#prefix::String(#cmd.into())) + } + Self::Object { cmd, cwd, args } => { + let cmd = cmd.as_str(); + let cwd = opt_str_lit(cwd.as_ref()); + let args = opt_lit(args.as_ref().map(|v| vec_lit(v, str_lit)).as_ref()); + quote!(#prefix::Object { + cmd: #cmd.into(), + cwd: #cwd, + args: #args, + }) + } + }) + } + } + impl ToTokens for BuildConfig { fn to_tokens(&self, tokens: &mut TokenStream) { let dev_url = opt_lit(self.dev_url.as_ref().map(url_lit).as_ref()); let frontend_dist = opt_lit(self.frontend_dist.as_ref()); - let runner = quote!(None); + let runner = opt_lit(self.runner.as_ref()); let before_dev_command = quote!(None); let before_build_command = quote!(None); let before_bundle_command = quote!(None); @@ -3824,4 +3918,212 @@ mod test { assert_eq!(Color(0, 0, 0, 255), "#000000ff".parse().unwrap()); assert_eq!(Color(0, 255, 0, 255), "#00ff00ff".parse().unwrap()); } + + #[test] + fn test_runner_config_string_format() { + use super::RunnerConfig; + + // Test string format deserialization + let json = r#""cargo""#; + let runner: RunnerConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(runner.cmd(), "cargo"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + + // Test string format serialization + let serialized = serde_json::to_string(&runner).unwrap(); + assert_eq!(serialized, r#""cargo""#); + } + + #[test] + fn test_runner_config_object_format_full() { + use super::RunnerConfig; + + // Test object format with all fields + let json = r#"{"cmd": "my_runner", "cwd": "/tmp/build", "args": ["--quiet", "--verbose"]}"#; + let runner: RunnerConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(runner.cmd(), "my_runner"); + assert_eq!(runner.cwd(), Some("/tmp/build")); + assert_eq!( + runner.args(), + Some(&["--quiet".to_string(), "--verbose".to_string()][..]) + ); + + // Test object format serialization + let serialized = serde_json::to_string(&runner).unwrap(); + let deserialized: RunnerConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(runner, deserialized); + } + + #[test] + fn test_runner_config_object_format_minimal() { + use super::RunnerConfig; + + // Test object format with only cmd field + let json = r#"{"cmd": "cross"}"#; + let runner: RunnerConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(runner.cmd(), "cross"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + } + + #[test] + fn test_runner_config_default() { + use super::RunnerConfig; + + let default_runner = RunnerConfig::default(); + assert_eq!(default_runner.cmd(), "cargo"); + assert_eq!(default_runner.cwd(), None); + assert_eq!(default_runner.args(), None); + } + + #[test] + fn test_runner_config_from_str() { + use super::RunnerConfig; + + // Test From<&str> trait + let runner: RunnerConfig = "my_runner".into(); + assert_eq!(runner.cmd(), "my_runner"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + } + + #[test] + fn test_runner_config_from_string() { + use super::RunnerConfig; + + // Test From trait + let runner: RunnerConfig = "another_runner".to_string().into(); + assert_eq!(runner.cmd(), "another_runner"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + } + + #[test] + fn test_runner_config_from_str_parse() { + use super::RunnerConfig; + use std::str::FromStr; + + // Test FromStr trait + let runner = RunnerConfig::from_str("parsed_runner").unwrap(); + assert_eq!(runner.cmd(), "parsed_runner"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + } + + #[test] + fn test_runner_config_in_build_config() { + use super::BuildConfig; + + // Test string format in BuildConfig + let json = r#"{"runner": "cargo"}"#; + let build_config: BuildConfig = serde_json::from_str(json).unwrap(); + + let runner = build_config.runner.unwrap(); + assert_eq!(runner.cmd(), "cargo"); + assert_eq!(runner.cwd(), None); + assert_eq!(runner.args(), None); + } + + #[test] + fn test_runner_config_in_build_config_object() { + use super::BuildConfig; + + // Test object format in BuildConfig + let json = r#"{"runner": {"cmd": "cross", "cwd": "/workspace", "args": ["--target", "x86_64-unknown-linux-gnu"]}}"#; + let build_config: BuildConfig = serde_json::from_str(json).unwrap(); + + let runner = build_config.runner.unwrap(); + assert_eq!(runner.cmd(), "cross"); + assert_eq!(runner.cwd(), Some("/workspace")); + assert_eq!( + runner.args(), + Some( + &[ + "--target".to_string(), + "x86_64-unknown-linux-gnu".to_string() + ][..] + ) + ); + } + + #[test] + fn test_runner_config_in_full_config() { + use super::Config; + + // Test runner config in full Tauri config + let json = r#"{ + "productName": "Test App", + "version": "1.0.0", + "identifier": "com.test.app", + "build": { + "runner": { + "cmd": "my_custom_cargo", + "cwd": "/tmp/build", + "args": ["--quiet", "--verbose"] + } + } + }"#; + + let config: Config = serde_json::from_str(json).unwrap(); + let runner = config.build.runner.unwrap(); + + assert_eq!(runner.cmd(), "my_custom_cargo"); + assert_eq!(runner.cwd(), Some("/tmp/build")); + assert_eq!( + runner.args(), + Some(&["--quiet".to_string(), "--verbose".to_string()][..]) + ); + } + + #[test] + fn test_runner_config_equality() { + use super::RunnerConfig; + + let runner1 = RunnerConfig::String("cargo".to_string()); + let runner2 = RunnerConfig::String("cargo".to_string()); + let runner3 = RunnerConfig::String("cross".to_string()); + + assert_eq!(runner1, runner2); + assert_ne!(runner1, runner3); + + let runner4 = RunnerConfig::Object { + cmd: "cargo".to_string(), + cwd: Some("/tmp".to_string()), + args: Some(vec!["--quiet".to_string()]), + }; + let runner5 = RunnerConfig::Object { + cmd: "cargo".to_string(), + cwd: Some("/tmp".to_string()), + args: Some(vec!["--quiet".to_string()]), + }; + + assert_eq!(runner4, runner5); + assert_ne!(runner1, runner4); + } + + #[test] + fn test_runner_config_untagged_serialization() { + use super::RunnerConfig; + + // Test that serde untagged works correctly - string should serialize as string, not object + let string_runner = RunnerConfig::String("cargo".to_string()); + let string_json = serde_json::to_string(&string_runner).unwrap(); + assert_eq!(string_json, r#""cargo""#); + + // Test that object serializes as object + let object_runner = RunnerConfig::Object { + cmd: "cross".to_string(), + cwd: None, + args: None, + }; + let object_json = serde_json::to_string(&object_runner).unwrap(); + assert!(object_json.contains("\"cmd\":\"cross\"")); + // With skip_serializing_none, null values should not be included + assert!(object_json.contains("\"cwd\":null") || !object_json.contains("cwd")); + assert!(object_json.contains("\"args\":null") || !object_json.contains("args")); + } }