feat(cli): add mobile run commands, closes #13196 (#14120)

* feat(cli): add mobile run commands, closes #13196

* headers

* debug by default

* fix android env

* implement watcher

* clippy

* skip ipa build
This commit is contained in:
Lucas Fernandes Nogueira
2025-10-08 07:58:17 -03:00
committed by GitHub
parent 006d592837
commit 75082cc5b3
9 changed files with 397 additions and 35 deletions

6
.changes/mobile-run.md Normal file
View File

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

View File

@@ -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<R: Fn() -> crate::Result<Box<dyn DevProcess + Send>>>(
&mut self,
options: WatcherOptions,
runner: R,
) -> crate::Result<()>;
}

View File

@@ -115,6 +115,12 @@ pub struct MobileOptions {
pub additional_watch_folders: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct WatcherOptions {
pub config: Vec<ConfigValue>,
pub additional_watch_folders: Vec<PathBuf>,
}
#[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::<Vec<_>>();
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<R: Fn() -> crate::Result<Box<dyn DevProcess + Send>>>(
&mut self,
options: WatcherOptions,
runner: R,
) -> crate::Result<()> {
let merge_configs = options.config.iter().map(|c| &c.0).collect::<Vec<_>>();
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(

View File

@@ -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<bool>,
/// Build AABs.
#[clap(long)]
pub aab: bool,
pub aab: Option<bool>,
/// 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<TargetDevice>,
}
impl From<Options> for BuildOptions {
@@ -104,7 +107,15 @@ impl From<Options> 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<BuiltApplication> {
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<OptionsHandle> {
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)
}

View File

@@ -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)?,
}

View File

@@ -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<Vec<String>>,
/// 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<ConfigValue>,
/// Disable the file watcher
#[clap(long)]
pub no_watch: bool,
/// Additional paths to watch for changes.
#[clap(long)]
pub additional_watch_folders: Vec<PathBuf>,
/// Open Android Studio
#[clap(short, long)]
pub open: bool,
/// Runs on the given device name
pub device: Option<String>,
/// 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<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,
}
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<dyn DevProcess + Send>)
.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(())
}

View File

@@ -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<String>,
pub targets: Option<Vec<String>>,
/// List of cargo features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..))]
pub features: Option<Vec<String>>,
@@ -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<TargetDevice>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
@@ -156,7 +159,15 @@ impl From<Options> 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<BuiltApplication> {
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)
}

View File

@@ -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)?,
}

View File

@@ -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<Vec<String>>,
/// 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<ConfigValue>,
/// Disable the file watcher
#[clap(long)]
pub no_watch: bool,
/// Additional paths to watch for changes.
#[clap(long)]
pub additional_watch_folders: Vec<PathBuf>,
/// Open Xcode
#[clap(short, long)]
pub open: bool,
/// Runs on the given device name
pub device: Option<String>,
/// 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<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,
}
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<dyn DevProcess + Send>)
.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(())
}