diff --git a/.changes/mobile-run.md b/.changes/mobile-run.md new file mode 100644 index 000000000..9026f2273 --- /dev/null +++ b/.changes/mobile-run.md @@ -0,0 +1,6 @@ +--- +"@tauri-apps/cli": minor:feat +"tauri-cli": minor:feat +--- + +Added `ios run` and `android run` commands to run the app in production mode. diff --git a/crates/tauri-cli/src/interface/mod.rs b/crates/tauri-cli/src/interface/mod.rs index e29377b17..e0f74ebf7 100644 --- a/crates/tauri-cli/src/interface/mod.rs +++ b/crates/tauri-cli/src/interface/mod.rs @@ -14,7 +14,7 @@ use std::{ use crate::{error::Context, helpers::config::Config}; use tauri_bundler::bundle::{PackageType, Settings, SettingsBuilder}; -pub use rust::{MobileOptions, Options, Rust as AppInterface}; +pub use rust::{MobileOptions, Options, Rust as AppInterface, WatcherOptions}; pub trait DevProcess { fn kill(&self) -> std::io::Result<()>; @@ -113,4 +113,9 @@ pub trait Interface: Sized { options: MobileOptions, runner: R, ) -> crate::Result<()>; + fn watch crate::Result>>( + &mut self, + options: WatcherOptions, + runner: R, + ) -> crate::Result<()>; } diff --git a/crates/tauri-cli/src/interface/rust.rs b/crates/tauri-cli/src/interface/rust.rs index 710bc7e96..c52c4f508 100644 --- a/crates/tauri-cli/src/interface/rust.rs +++ b/crates/tauri-cli/src/interface/rust.rs @@ -115,6 +115,12 @@ pub struct MobileOptions { pub additional_watch_folders: Vec, } +#[derive(Debug, Clone)] +pub struct WatcherOptions { + pub config: Vec, + pub additional_watch_folders: Vec, +} + #[derive(Debug)] pub struct RustupTarget { name: String, @@ -245,12 +251,26 @@ impl Interface for Rust { runner(options)?; Ok(()) } else { - let merge_configs = options.config.iter().map(|c| &c.0).collect::>(); - let run = Arc::new(|_rust: &mut Rust| runner(options.clone())); - self.run_dev_watcher(&options.additional_watch_folders, &merge_configs, run) + self.watch( + WatcherOptions { + config: options.config.clone(), + additional_watch_folders: options.additional_watch_folders.clone(), + }, + move || runner(options.clone()), + ) } } + fn watch crate::Result>>( + &mut self, + options: WatcherOptions, + runner: R, + ) -> crate::Result<()> { + let merge_configs = options.config.iter().map(|c| &c.0).collect::>(); + let run = Arc::new(|_rust: &mut Rust| runner()); + self.run_dev_watcher(&options.additional_watch_folders, &merge_configs, run) + } + fn env(&self) -> HashMap<&str, String> { let mut env = HashMap::new(); env.insert( diff --git a/crates/tauri-cli/src/mobile/android/build.rs b/crates/tauri-cli/src/mobile/android/build.rs index 47fd33a56..a8f5ff2d2 100644 --- a/crates/tauri-cli/src/mobile/android/build.rs +++ b/crates/tauri-cli/src/mobile/android/build.rs @@ -15,7 +15,7 @@ use crate::{ flock, }, interface::{AppInterface, Interface, Options as InterfaceOptions}, - mobile::{write_options, CliOptions}, + mobile::{write_options, CliOptions, TargetDevice}, ConfigValue, Error, Result, }; use clap::{ArgAction, Parser}; @@ -63,10 +63,10 @@ pub struct Options { pub split_per_abi: bool, /// Build APKs. #[clap(long)] - pub apk: bool, + pub apk: Option, /// Build AABs. #[clap(long)] - pub aab: bool, + pub aab: Option, /// Open Android Studio #[clap(short, long)] pub open: bool, @@ -83,6 +83,9 @@ pub struct Options { /// 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, + /// Target device of this build + #[clap(skip)] + pub target_device: Option, } impl From for BuildOptions { @@ -104,7 +107,15 @@ impl From for BuildOptions { } } -pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { +pub struct BuiltApplication { + pub config: AndroidConfig, + pub interface: AppInterface, + // prevent drop + #[allow(dead_code)] + options_handle: OptionsHandle, +} + +pub fn command(options: Options, noise_level: NoiseLevel) -> Result { crate::helpers::app_paths::resolve(); delete_codegen_vars(); @@ -188,8 +199,8 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { .context("failed to build Android app")?; let open = options.open; - let _handle = run_build( - interface, + let options_handle = run_build( + &interface, options, build_options, tauri_config, @@ -203,12 +214,16 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { open_and_wait(&config, &env); } - Ok(()) + Ok(BuiltApplication { + config, + interface, + options_handle, + }) } #[allow(clippy::too_many_arguments)] fn run_build( - interface: AppInterface, + interface: &AppInterface, mut options: Options, build_options: BuildOptions, tauri_config: ConfigHandle, @@ -217,10 +232,10 @@ fn run_build( env: &mut Env, noise_level: NoiseLevel, ) -> Result { - if !(options.apk || options.aab) { + if !(options.apk.is_some() || options.aab.is_some()) { // if the user didn't specify the format to build, we'll do both - options.apk = true; - options.aab = true; + options.apk = Some(true); + options.aab = Some(true); } let interface_options = InterfaceOptions { @@ -241,13 +256,13 @@ fn run_build( noise_level, vars: Default::default(), config: build_options.config, - target_device: None, + target_device: options.target_device.clone(), }; let handle = write_options(tauri_config.lock().unwrap().as_ref().unwrap(), cli_options)?; inject_resources(config, tauri_config.lock().unwrap().as_ref().unwrap())?; - let apk_outputs = if options.apk { + let apk_outputs = if options.apk.unwrap_or_default() { apk::build( config, env, @@ -261,7 +276,7 @@ fn run_build( Vec::new() }; - let aab_outputs = if options.aab { + let aab_outputs = if options.aab.unwrap_or_default() { aab::build( config, env, @@ -275,8 +290,12 @@ fn run_build( Vec::new() }; - log_finished(apk_outputs, "APK"); - log_finished(aab_outputs, "AAB"); + if !apk_outputs.is_empty() { + log_finished(apk_outputs, "APK"); + } + if !aab_outputs.is_empty() { + log_finished(aab_outputs, "AAB"); + } Ok(handle) } diff --git a/crates/tauri-cli/src/mobile/android/mod.rs b/crates/tauri-cli/src/mobile/android/mod.rs index 43154ea2c..9a0475278 100644 --- a/crates/tauri-cli/src/mobile/android/mod.rs +++ b/crates/tauri-cli/src/mobile/android/mod.rs @@ -44,6 +44,7 @@ mod android_studio_script; mod build; mod dev; pub(crate) mod project; +mod run; const NDK_VERSION: &str = "29.0.13846066"; const SDK_VERSION: u8 = 36; @@ -96,6 +97,7 @@ enum Commands { Init(InitOptions), Dev(dev::Options), Build(build::Options), + Run(run::Options), #[clap(hide(true))] AndroidStudioScript(android_studio_script::Options), } @@ -114,7 +116,8 @@ pub fn command(cli: Cli, verbosity: u8) -> Result<()> { )? } Commands::Dev(options) => dev::command(options, noise_level)?, - Commands::Build(options) => build::command(options, noise_level)?, + Commands::Build(options) => build::command(options, noise_level).map(|_| ())?, + Commands::Run(options) => run::command(options, noise_level)?, Commands::AndroidStudioScript(options) => android_studio_script::command(options)?, } diff --git a/crates/tauri-cli/src/mobile/android/run.rs b/crates/tauri-cli/src/mobile/android/run.rs new file mode 100644 index 000000000..9c276c4d9 --- /dev/null +++ b/crates/tauri-cli/src/mobile/android/run.rs @@ -0,0 +1,152 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use cargo_mobile2::{ + android::target::Target, + opts::{FilterLevel, NoiseLevel, Profile}, + target::TargetTrait, +}; +use clap::{ArgAction, Parser}; +use std::path::PathBuf; + +use super::{configure_cargo, device_prompt, env}; +use crate::{ + error::Context, + interface::{DevProcess, Interface, WatcherOptions}, + mobile::{DevChild, TargetDevice}, + ConfigValue, Result, +}; + +#[derive(Debug, Clone, Parser)] +#[clap( + about = "Run your app in production mode on Android", + long_about = "Run your app in production mode on Android. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`." +)] +pub struct Options { + /// Run the app in release mode + #[clap(short, long)] + pub release: bool, + /// List of cargo features to activate + #[clap(short, long, action = ArgAction::Append, num_args(0..))] + pub features: Option>, + /// JSON strings or paths to JSON, JSON5 or TOML files to merge with the default configuration file + /// + /// Configurations are merged in the order they are provided, which means a particular value overwrites previous values when a config key-value pair conflicts. + /// + /// Note that a platform-specific file is looked up and merged with the default file by default + /// (tauri.macos.conf.json, tauri.linux.conf.json, tauri.windows.conf.json, tauri.android.conf.json and tauri.ios.conf.json) + /// but you can use this for more specific use cases such as different build flavors. + #[clap(short, long)] + pub config: Vec, + /// Disable the file watcher + #[clap(long)] + pub no_watch: bool, + /// Additional paths to watch for changes. + #[clap(long)] + pub additional_watch_folders: Vec, + /// Open Android Studio + #[clap(short, long)] + pub open: bool, + /// Runs on the given device name + pub device: Option, + /// Command line arguments passed to the runner. + /// Use `--` to explicitly mark the start of the arguments. + /// 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, +} + +pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { + let mut env = env(false)?; + + let device = if options.open { + None + } else { + match device_prompt(&env, options.device.as_deref()) { + Ok(d) => Some(d), + Err(e) => { + log::error!("{e}"); + None + } + } + }; + + let mut built_application = super::build::command( + super::build::Options { + debug: !options.release, + targets: device.as_ref().map(|d| { + vec![Target::all() + .iter() + .find(|(_key, t)| t.arch == d.target().arch) + .map(|(key, _t)| key.to_string()) + .expect("Target not found")] + }), + features: options.features, + config: options.config.clone(), + split_per_abi: true, + apk: Some(false), + aab: Some(false), + open: options.open, + ci: false, + args: options.args, + ignore_version_mismatches: options.ignore_version_mismatches, + target_device: device.as_ref().map(|d| TargetDevice { + id: d.serial_no().to_string(), + name: d.name().to_string(), + }), + }, + noise_level, + )?; + + configure_cargo(&mut env, &built_application.config)?; + + // options.open is handled by the build command + // so all we need to do here is run the app on the selected device + if let Some(device) = device { + let config = built_application.config.clone(); + let release = options.release; + let runner = move || { + device + .run( + &config, + &env, + noise_level, + if !release { + Profile::Debug + } else { + Profile::Release + }, + Some(match noise_level { + NoiseLevel::Polite => FilterLevel::Info, + NoiseLevel::LoudAndProud => FilterLevel::Debug, + NoiseLevel::FranklyQuitePedantic => FilterLevel::Verbose, + }), + false, + false, + ".MainActivity".into(), + ) + .map(|c| Box::new(DevChild::new(c)) as Box) + .context("failed to run Android app") + }; + + if options.no_watch { + runner()?; + } else { + built_application.interface.watch( + WatcherOptions { + config: options.config, + additional_watch_folders: options.additional_watch_folders, + }, + runner, + )?; + } + } + + Ok(()) +} diff --git a/crates/tauri-cli/src/mobile/ios/build.rs b/crates/tauri-cli/src/mobile/ios/build.rs index 8215e3890..9f4aab42c 100644 --- a/crates/tauri-cli/src/mobile/ios/build.rs +++ b/crates/tauri-cli/src/mobile/ios/build.rs @@ -17,7 +17,7 @@ use crate::{ plist::merge_plist, }, interface::{AppInterface, Interface, Options as InterfaceOptions}, - mobile::{ios::ensure_ios_runtime_installed, write_options, CliOptions}, + mobile::{ios::ensure_ios_runtime_installed, write_options, CliOptions, TargetDevice}, ConfigValue, Error, Result, }; use clap::{ArgAction, Parser, ValueEnum}; @@ -57,7 +57,7 @@ pub struct Options { default_value = Target::DEFAULT_KEY, value_parser(clap::builder::PossibleValuesParser::new(Target::name_list())) )] - pub targets: Vec, + pub targets: Option>, /// List of cargo features to activate #[clap(short, long, action = ArgAction::Append, num_args(0..))] pub features: Option>, @@ -94,6 +94,9 @@ pub struct Options { /// 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, + /// Target device of this build + #[clap(skip)] + pub target_device: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -156,7 +159,15 @@ impl From for BuildOptions { } } -pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { +pub struct BuiltApplication { + pub config: AppleConfig, + pub interface: AppInterface, + // prevent drop + #[allow(dead_code)] + options_handle: OptionsHandle, +} + +pub fn command(options: Options, noise_level: NoiseLevel) -> Result { crate::helpers::app_paths::resolve(); let mut build_options: BuildOptions = options.clone().into(); @@ -165,7 +176,8 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { .get( options .targets - .first() + .as_ref() + .and_then(|t| t.first()) .map(|t| t.as_str()) .unwrap_or(Target::DEFAULT_KEY), ) @@ -318,8 +330,8 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { }; let open = options.open; - let _handle = run_build( - interface, + let options_handle = run_build( + &interface, options, build_options, tauri_config, @@ -332,12 +344,16 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { open_and_wait(&config, &env); } - Ok(()) + Ok(BuiltApplication { + config, + interface, + options_handle, + }) } #[allow(clippy::too_many_arguments)] fn run_build( - interface: AppInterface, + interface: &AppInterface, options: Options, mut build_options: BuildOptions, tauri_config: ConfigHandle, @@ -351,7 +367,7 @@ fn run_build( Profile::Release }; - crate::build::setup(&interface, &mut build_options, tauri_config.clone(), true)?; + crate::build::setup(interface, &mut build_options, tauri_config.clone(), true)?; let app_settings = interface.app_settings(); let out_dir = app_settings.out_dir(&InterfaceOptions { @@ -369,7 +385,7 @@ fn run_build( noise_level, vars: Default::default(), config: build_options.config.clone(), - target_device: None, + target_device: options.target_device.clone(), }; let handle = write_options(tauri_config.lock().unwrap().as_ref().unwrap(), cli_options)?; @@ -379,9 +395,15 @@ fn run_build( let mut out_files = Vec::new(); + let force_skip_target_fallback = options.targets.as_ref().is_some_and(|t| t.is_empty()); + call_for_targets_with_fallback( - options.targets.iter(), - &detect_target_ok, + options.targets.unwrap_or_default().iter(), + if force_skip_target_fallback { + &|_| None + } else { + &detect_target_ok + }, env, |target: &Target| -> Result<()> { let mut app_version = config.bundle_version().to_string(); @@ -506,7 +528,9 @@ fn run_build( ) .map_err(|e: TargetInvalid| Error::GenericError(e.to_string()))??; - log_finished(out_files, "iOS Bundle"); + if !out_files.is_empty() { + log_finished(out_files, "iOS Bundle"); + } Ok(handle) } diff --git a/crates/tauri-cli/src/mobile/ios/mod.rs b/crates/tauri-cli/src/mobile/ios/mod.rs index c01ebc693..ca4aade41 100644 --- a/crates/tauri-cli/src/mobile/ios/mod.rs +++ b/crates/tauri-cli/src/mobile/ios/mod.rs @@ -49,6 +49,7 @@ use std::{ mod build; mod dev; pub(crate) mod project; +mod run; mod xcode_script; pub const APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME: &str = "APPLE_DEVELOPMENT_TEAM"; @@ -95,6 +96,7 @@ enum Commands { Init(InitOptions), Dev(dev::Options), Build(build::Options), + Run(run::Options), #[clap(hide(true))] XcodeScript(xcode_script::Options), } @@ -113,7 +115,8 @@ pub fn command(cli: Cli, verbosity: u8) -> Result<()> { )? } Commands::Dev(options) => dev::command(options, noise_level)?, - Commands::Build(options) => build::command(options, noise_level)?, + Commands::Build(options) => build::command(options, noise_level).map(|_| ())?, + Commands::Run(options) => run::command(options, noise_level)?, Commands::XcodeScript(options) => xcode_script::command(options)?, } diff --git a/crates/tauri-cli/src/mobile/ios/run.rs b/crates/tauri-cli/src/mobile/ios/run.rs new file mode 100644 index 000000000..0ae1cef12 --- /dev/null +++ b/crates/tauri-cli/src/mobile/ios/run.rs @@ -0,0 +1,130 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use cargo_mobile2::opts::{NoiseLevel, Profile}; +use clap::{ArgAction, Parser}; + +use super::{device_prompt, env}; +use crate::{ + error::Context, + interface::{DevProcess, Interface, WatcherOptions}, + mobile::{DevChild, TargetDevice}, + ConfigValue, Result, +}; + +#[derive(Debug, Clone, Parser)] +#[clap( + about = "Run your app in production mode on iOS", + long_about = "Run your app in production mode on iOS. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`." +)] +pub struct Options { + /// Run the app in release mode + #[clap(short, long)] + pub release: bool, + /// List of cargo features to activate + #[clap(short, long, action = ArgAction::Append, num_args(0..))] + pub features: Option>, + /// JSON strings or paths to JSON, JSON5 or TOML files to merge with the default configuration file + /// + /// Configurations are merged in the order they are provided, which means a particular value overwrites previous values when a config key-value pair conflicts. + /// + /// Note that a platform-specific file is looked up and merged with the default file by default + /// (tauri.macos.conf.json, tauri.linux.conf.json, tauri.windows.conf.json, tauri.android.conf.json and tauri.ios.conf.json) + /// but you can use this for more specific use cases such as different build flavors. + #[clap(short, long)] + pub config: Vec, + /// Disable the file watcher + #[clap(long)] + pub no_watch: bool, + /// Additional paths to watch for changes. + #[clap(long)] + pub additional_watch_folders: Vec, + /// Open Xcode + #[clap(short, long)] + pub open: bool, + /// Runs on the given device name + pub device: Option, + /// Command line arguments passed to the runner. + /// Use `--` to explicitly mark the start of the arguments. + /// 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, +} + +pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { + let env = env().context("failed to load iOS environment")?; + let device = if options.open { + None + } else { + match device_prompt(&env, options.device.as_deref()) { + Ok(d) => Some(d), + Err(e) => { + log::error!("{e}"); + None + } + } + }; + + let mut built_application = super::build::command( + super::build::Options { + debug: !options.release, + targets: Some(vec![]), /* skips IPA build since there's no target */ + features: None, + config: options.config.clone(), + build_number: None, + open: options.open, + ci: false, + export_method: None, + args: options.args, + ignore_version_mismatches: options.ignore_version_mismatches, + target_device: device.as_ref().map(|d| TargetDevice { + id: d.id().to_string(), + name: d.name().to_string(), + }), + }, + noise_level, + )?; + + // options.open is handled by the build command + // so all we need to do here is run the app on the selected device + if let Some(device) = device { + let runner = move || { + device + .run( + &built_application.config, + &env, + noise_level, + false, // do not quit on app exit + if !options.release { + Profile::Debug + } else { + Profile::Release + }, + ) + .map(|c| Box::new(DevChild::new(c)) as Box) + .context("failed to run iOS app") + }; + + if options.no_watch { + runner()?; + } else { + built_application.interface.watch( + WatcherOptions { + config: options.config, + additional_watch_folders: options.additional_watch_folders, + }, + runner, + )?; + } + } + + Ok(()) +}