// Copyright 2019-2024 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use super::{ configure_cargo, device_prompt, ensure_init, env, get_app, get_config, inject_assets, merge_plist, open_and_wait, MobileTarget, }; use crate::{ dev::Options as DevOptions, helpers::{ app_paths::tauri_dir, config::{get as get_tauri_config, reload as reload_config, ConfigHandle}, flock, }, interface::{AppInterface, AppSettings, Interface, MobileOptions, Options as InterfaceOptions}, mobile::{write_options, CliOptions, DevChild, DevProcess}, ConfigValue, Result, }; use clap::{ArgAction, Parser}; use anyhow::Context; use cargo_mobile2::{ apple::{ config::Config as AppleConfig, device::{Device, DeviceKind}, }, config::app::App, env::Env, opts::{NoiseLevel, Profile}, }; use std::{ env::set_current_dir, net::{IpAddr, Ipv4Addr, SocketAddr}, sync::OnceLock, }; const PHYSICAL_IPHONE_DEV_WARNING: &str = "To develop on physical phones you need the `--host` option (not required for Simulators). See the documentation for more information: https://v2.tauri.app/develop/#development-server"; #[derive(Debug, Clone, Parser)] #[clap( about = "Run your app in development mode on iOS", long_about = "Run your app in development mode on iOS with hot-reloading for the Rust code. It makes use of the `build.devUrl` property from your `tauri.conf.json` file. It also runs your `build.beforeDevCommand` which usually starts your frontend devServer. When connected to a physical iOS device, the public network address must be used instead of `localhost` for the devUrl property. Tauri makes that change automatically, but your dev server might need a different configuration to listen on the public address. You can check the `TAURI_DEV_HOST` environment variable to determine whether the public network should be used or not." )] pub struct Options { /// List of cargo features to activate #[clap(short, long, action = ArgAction::Append, num_args(0..))] pub features: Option>, /// Exit on panic #[clap(short, long)] exit_on_panic: bool, /// JSON string or path to JSON file to merge with tauri.conf.json #[clap(short, long)] pub config: Option, /// Run the code in release mode #[clap(long = "release")] pub release_mode: bool, /// Skip waiting for the frontend dev server to start before building the tauri application. #[clap(long, env = "TAURI_CLI_NO_DEV_SERVER_WAIT")] pub no_dev_server_wait: bool, /// Disable the file watcher #[clap(long)] pub no_watch: bool, /// Open Xcode instead of trying to run on a connected device #[clap(short, long)] pub open: bool, /// Runs on the given device name pub device: Option, /// Force prompting for an IP to use to connect to the dev server on mobile. #[clap(long)] pub force_ip_prompt: bool, /// Use the public network address for the development server. /// If an actual address it provided, it is used instead of prompting to pick one. /// /// This option is particularly useful along the `--open` flag when you intend on running on a physical device. /// /// This replaces the devUrl configuration value to match the public network address host, /// it is your responsability to set up your development server to listen on this address /// by using 0.0.0.0 as host for instance. /// /// When this is set or when running on an iOS device the CLI sets the `TAURI_DEV_HOST` /// environment variable so you can check this on your framework's configuration to expose the development server /// on the public network address. #[clap(long)] pub host: Option>, /// Disable the built-in dev server for static files. #[clap(long)] pub no_dev_server: bool, /// Specify port for the built-in dev server for static files. Defaults to 1430. #[clap(long, env = "TAURI_CLI_PORT")] pub port: Option, } impl From for DevOptions { fn from(options: Options) -> Self { Self { runner: None, target: None, features: options.features, exit_on_panic: options.exit_on_panic, config: options.config, release_mode: options.release_mode, args: Vec::new(), no_watch: options.no_watch, no_dev_server: options.no_dev_server, no_dev_server_wait: options.no_dev_server_wait, port: options.port, host: None, } } } pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { crate::helpers::app_paths::resolve(); let result = run_command(options, noise_level); if result.is_err() { crate::dev::kill_before_dev_process(); } result } fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> { let env = env()?; 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 dev_options: DevOptions = options.clone().into(); let target_triple = device .as_ref() .map(|d| d.target().triple.to_string()) .unwrap_or_else(|| "aarch64-apple-ios".into()); dev_options.target = Some(target_triple.clone()); let tauri_config = get_tauri_config( tauri_utils::platform::Target::Ios, options.config.as_ref().map(|c| &c.0), )?; let (interface, app, config) = { let tauri_config_guard = tauri_config.lock().unwrap(); let tauri_config_ = tauri_config_guard.as_ref().unwrap(); let interface = AppInterface::new(tauri_config_, Some(target_triple))?; let app = get_app(tauri_config_, &interface); let (config, _metadata) = get_config( &app, tauri_config_, dev_options.features.as_ref(), &Default::default(), ); (interface, app, config) }; let tauri_path = tauri_dir(); set_current_dir(tauri_path).with_context(|| "failed to change current working directory")?; ensure_init( &tauri_config, config.app(), config.project_dir(), MobileTarget::Ios, )?; inject_assets(&config)?; let info_plist_path = config .project_dir() .join(config.scheme()) .join("Info.plist"); merge_plist( vec![ tauri_path.join("Info.plist").into(), tauri_path.join("Info.ios.plist").into(), ], &info_plist_path, )?; run_dev( interface, options, dev_options, tauri_config, device, env, &app, &config, noise_level, ) } fn local_ip_address(force: bool) -> &'static IpAddr { static LOCAL_IP: OnceLock = OnceLock::new(); LOCAL_IP.get_or_init(|| { let prompt_for_ip = || { let addresses: Vec = local_ip_address::list_afinet_netifas() .expect("failed to list networks") .into_iter() .map(|(_, ipaddr)| ipaddr) .filter(|ipaddr| match ipaddr { IpAddr::V4(i) => i != &Ipv4Addr::LOCALHOST, IpAddr::V6(i) => i.to_string().ends_with("::2"), }) .collect(); match addresses.len() { 0 => panic!("No external IP detected."), 1 => { let ipaddr = addresses.first().unwrap(); *ipaddr } _ => { let selected = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) .with_prompt( "Failed to detect external IP, What IP should we use to access your development server?", ) .items(&addresses) .default(0) .interact() .expect("failed to select external IP"); *addresses.get(selected).unwrap() } } }; let ip = if force { prompt_for_ip() } else { local_ip_address::local_ip().unwrap_or_else(|_| prompt_for_ip()) }; log::info!("Using {ip} to access the development server."); ip }) } fn use_network_address_for_dev_url( config: &ConfigHandle, options: &mut Options, dev_options: &mut DevOptions, ) -> crate::Result<()> { let mut dev_url = config .lock() .unwrap() .as_ref() .unwrap() .build .dev_url .clone(); let ip = if let Some(url) = &mut dev_url { let localhost = match url.host() { Some(url::Host::Domain(d)) => d == "localhost", Some(url::Host::Ipv4(i)) => { i == std::net::Ipv4Addr::LOCALHOST || i == std::net::Ipv4Addr::UNSPECIFIED } _ => false, }; if localhost { let ip = options .host .unwrap_or_default() .unwrap_or_else(|| *local_ip_address(options.force_ip_prompt)); log::info!( "Replacing devUrl host with {ip}. {}.", "If your frontend is not listening on that address, try configuring your development server to use the `TAURI_DEV_HOST` environment variable or 0.0.0.0 as host" ); *url = url::Url::parse(&format!( "{}://{}{}", url.scheme(), SocketAddr::new(ip, url.port_or_known_default().unwrap()), url.path() ))?; if let Some(c) = &mut options.config { if let Some(build) = c .0 .as_object_mut() .and_then(|root| root.get_mut("build")) .and_then(|build| build.as_object_mut()) { build.insert("devUrl".into(), url.to_string().into()); } } else { let mut build = serde_json::Map::new(); build.insert("devUrl".into(), url.to_string().into()); options .config .replace(crate::ConfigValue(serde_json::json!({ "build": build }))); } reload_config(options.config.as_ref().map(|c| &c.0))?; Some(ip) } else { None } } else if !dev_options.no_dev_server { let ip = options .host .unwrap_or_default() .unwrap_or_else(|| *local_ip_address(options.force_ip_prompt)); dev_options.host.replace(ip); Some(ip) } else { None }; if let Some(ip) = ip { std::env::set_var("TAURI_DEV_HOST", ip.to_string()); if ip.is_ipv6() { // in this case we can't ping the server for some reason dev_options.no_dev_server_wait = true; } } Ok(()) } #[allow(clippy::too_many_arguments)] fn run_dev( mut interface: AppInterface, mut options: Options, mut dev_options: DevOptions, tauri_config: ConfigHandle, device: Option, env: Env, app: &App, config: &AppleConfig, noise_level: NoiseLevel, ) -> Result<()> { // when running on an actual device we must use the network IP if options.host.is_some() || device .as_ref() .map(|device| !matches!(device.kind(), DeviceKind::Simulator)) .unwrap_or(false) { use_network_address_for_dev_url(&tauri_config, &mut options, &mut dev_options)?; } crate::dev::setup(&interface, &mut dev_options, tauri_config.clone())?; let app_settings = interface.app_settings(); let bin_path = app_settings.app_binary_path(&InterfaceOptions { debug: !dev_options.release_mode, target: dev_options.target.clone(), ..Default::default() })?; let out_dir = bin_path.parent().unwrap(); let _lock = flock::open_rw(out_dir.join("lock").with_extension("ios"), "iOS")?; let set_host = options.host.is_some(); configure_cargo(app, None)?; let open = options.open; let exit_on_panic = options.exit_on_panic; let no_watch = options.no_watch; interface.mobile_dev( MobileOptions { debug: true, features: options.features, args: Vec::new(), config: dev_options.config.clone(), no_watch: options.no_watch, }, |options| { let cli_options = CliOptions { dev: true, features: options.features.clone(), args: options.args.clone(), noise_level, vars: Default::default(), config: dev_options.config.clone(), }; let _handle = write_options( &tauri_config.lock().unwrap().as_ref().unwrap().identifier, cli_options, )?; if open { if !set_host { log::warn!("{PHYSICAL_IPHONE_DEV_WARNING}"); } open_and_wait(config, &env) } else if let Some(device) = &device { match run(device, options, config, &env) { Ok(c) => { crate::dev::wait_dev_process(c.clone(), move |status, reason| { crate::dev::on_app_exit(status, reason, exit_on_panic, no_watch) }); Ok(Box::new(c) as Box) } Err(e) => { crate::dev::kill_before_dev_process(); Err(e) } } } else { if !set_host { log::warn!("{PHYSICAL_IPHONE_DEV_WARNING}"); } open_and_wait(config, &env) } }, ) } fn run( device: &Device<'_>, options: MobileOptions, config: &AppleConfig, env: &Env, ) -> crate::Result { let profile = if options.debug { Profile::Debug } else { Profile::Release }; device .run( config, env, NoiseLevel::FranklyQuitePedantic, false, // do not quit on app exit profile, ) .map(DevChild::new) .map_err(Into::into) }