diff --git a/.changes/enhance-android-port-forwarding.md b/.changes/enhance-android-port-forwarding.md new file mode 100644 index 000000000000..505869c7a2b4 --- /dev/null +++ b/.changes/enhance-android-port-forwarding.md @@ -0,0 +1,6 @@ +--- +'tauri-cli': 'patch:enhance' +'@tauri-apps/cli': 'patch:enhance' +--- + +Enhance port forwarding on `android dev` to be more resilient and tolerate delays when booting up devices. diff --git a/crates/tauri-cli/src/mobile/android/android_studio_script.rs b/crates/tauri-cli/src/mobile/android/android_studio_script.rs index 08e8ae2ddd83..32f936130004 100644 --- a/crates/tauri-cli/src/mobile/android/android_studio_script.rs +++ b/crates/tauri-cli/src/mobile/android/android_studio_script.rs @@ -6,6 +6,7 @@ use super::{detect_target_ok, ensure_init, env, get_app, get_config, read_option use crate::{ helpers::config::get as get_tauri_config, interface::{AppInterface, Interface}, + mobile::CliOptions, Result, }; use clap::{ArgAction, Parser}; @@ -87,36 +88,17 @@ pub fn command(options: Options) -> Result<()> { .dev_url .clone(); - if let Some(port) = dev_url.and_then(|url| url.port_or_known_default()) { - let forward = format!("tcp:{port}"); - log::info!("Forwarding port {port} with adb"); + if let Some(url) = dev_url { + let localhost = match url.host() { + Some(url::Host::Domain(d)) => d == "localhost", + Some(url::Host::Ipv4(i)) => i == std::net::Ipv4Addr::LOCALHOST, + _ => false, + }; - let devices = adb::device_list(&env).unwrap_or_default(); - - // clear port forwarding for all devices - for device in &devices { - remove_adb_reverse(&env, device.serial_no(), &forward); - } - - // if there's a known target, we should force use it - if let Some(target_device) = &cli_options.target_device { - run_adb_reverse(&env, &target_device.id, &forward, &forward).with_context(|| { - format!( - "failed to forward port with adb, is the {} device connected?", - target_device.name, - ) - })?; - } else if devices.len() == 1 { - let device = devices.first().unwrap(); - run_adb_reverse(&env, device.serial_no(), &forward, &forward).with_context(|| { - format!( - "failed to forward port with adb, is the {} device connected?", - device.name(), - ) - })?; - } else if devices.len() > 1 { - anyhow::bail!("Multiple Android devices are connected ({}), please disconnect devices you do not intend to use so Tauri can determine which to use", - devices.iter().map(|d| d.name()).collect::>().join(", ")); + if localhost { + if let Some(port) = url.port_or_known_default() { + adb_forward_port(port, &env, &cli_options)?; + } } } } @@ -180,6 +162,102 @@ fn validate_lib(path: &Path) -> Result<()> { Ok(()) } +fn adb_forward_port( + port: u16, + env: &cargo_mobile2::android::env::Env, + cli_options: &CliOptions, +) -> Result<()> { + let forward = format!("tcp:{port}"); + log::info!("Forwarding port {port} with adb"); + + let mut devices = adb::device_list(env).unwrap_or_default(); + // if we could not detect any running device, let's wait a few seconds, it might be booting up + if devices.is_empty() { + log::warn!( + "ADB device list is empty, waiting a few seconds to see if there's any booting device..." + ); + + let max = 5; + let mut count = 0; + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + + devices = adb::device_list(env).unwrap_or_default(); + if !devices.is_empty() { + break; + } + + count += 1; + if count == max { + break; + } + } + } + + let target_device = if let Some(target_device) = &cli_options.target_device { + Some((target_device.id.clone(), target_device.name.clone())) + } else if devices.len() == 1 { + let device = devices.first().unwrap(); + Some((device.serial_no().to_string(), device.name().to_string())) + } else if devices.len() > 1 { + anyhow::bail!("Multiple Android devices are connected ({}), please disconnect devices you do not intend to use so Tauri can determine which to use", + devices.iter().map(|d| d.name()).collect::>().join(", ")); + } else { + // when building the app without running to a device, we might have an empty devices list + None + }; + + if let Some((target_device_serial_no, target_device_name)) = target_device { + let mut already_forwarded = false; + + // clear port forwarding for all devices + for device in &devices { + let reverse_list_output = adb_reverse_list(env, device.serial_no())?; + + // check if the device has the port forwarded + if String::from_utf8_lossy(&reverse_list_output.stdout).contains(&forward) { + // device matches our target, we can skip forwarding + if device.serial_no() == target_device_serial_no { + log::debug!( + "device {} already has the forward for {}", + device.name(), + forward + ); + already_forwarded = true; + } + break; + } + } + + // if there's a known target, we should forward the port to it + if already_forwarded { + log::info!("{forward} already forwarded to {target_device_name}"); + } else { + loop { + run_adb_reverse(env, &target_device_serial_no, &forward, &forward).with_context(|| { + format!("failed to forward port with adb, is the {target_device_name} device connected?",) + })?; + + let reverse_list_output = adb_reverse_list(env, &target_device_serial_no)?; + // wait and retry until the port has actually been forwarded + if String::from_utf8_lossy(&reverse_list_output.stdout).contains(&forward) { + break; + } else { + log::warn!( + "waiting for the port to be forwarded to {}...", + target_device_name + ); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + } + } else { + log::warn!("no running devices detected with ADB; skipping port forwarding"); + } + + Ok(()) +} + fn run_adb_reverse( env: &cargo_mobile2::android::env::Env, device_serial_no: &str, @@ -193,15 +271,13 @@ fn run_adb_reverse( .run() } -fn remove_adb_reverse( +fn adb_reverse_list( env: &cargo_mobile2::android::env::Env, device_serial_no: &str, - remote: &str, -) { - // ignore errors in case the port is not forwarded - let _ = adb::adb(env, ["-s", device_serial_no, "reverse", "--remove", remote]) +) -> std::io::Result { + adb::adb(env, ["-s", device_serial_no, "reverse", "--list"]) .stdin_file(os_pipe::dup_stdin().unwrap()) - .stdout_file(os_pipe::dup_stdout().unwrap()) - .stderr_file(os_pipe::dup_stdout().unwrap()) - .run(); + .stdout_capture() + .stderr_capture() + .run() } diff --git a/crates/tauri-cli/src/mobile/android/dev.rs b/crates/tauri-cli/src/mobile/android/dev.rs index 312f9f2b409d..1a56d737a431 100644 --- a/crates/tauri-cli/src/mobile/android/dev.rs +++ b/crates/tauri-cli/src/mobile/android/dev.rs @@ -14,7 +14,9 @@ use crate::{ flock, }, interface::{AppInterface, Interface, MobileOptions, Options as InterfaceOptions}, - mobile::{write_options, CliOptions, DevChild, DevProcess, TargetDevice}, + mobile::{ + use_network_address_for_dev_url, write_options, CliOptions, DevChild, DevProcess, TargetDevice, + }, ConfigValue, Result, }; use clap::{ArgAction, Parser}; @@ -31,7 +33,7 @@ use cargo_mobile2::{ target::TargetTrait, }; -use std::env::set_current_dir; +use std::{env::set_current_dir, net::IpAddr}; #[derive(Debug, Clone, Parser)] #[clap( @@ -62,6 +64,23 @@ pub struct Options { 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 responsibility 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, @@ -177,6 +196,16 @@ fn run_dev( metadata: &AndroidMetadata, 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| !device.serial_no().starts_with("emulator")) + .unwrap_or(false) + { + use_network_address_for_dev_url(&tauri_config, &mut dev_options, options.force_ip_prompt)?; + } + crate::dev::setup(&interface, &mut dev_options, tauri_config.clone())?; let interface_options = InterfaceOptions { diff --git a/crates/tauri-cli/src/mobile/ios/dev.rs b/crates/tauri-cli/src/mobile/ios/dev.rs index bc0b0381f91e..c1d57376ca74 100644 --- a/crates/tauri-cli/src/mobile/ios/dev.rs +++ b/crates/tauri-cli/src/mobile/ios/dev.rs @@ -10,11 +10,11 @@ use crate::{ dev::Options as DevOptions, helpers::{ app_paths::tauri_dir, - config::{get as get_tauri_config, reload as reload_config, ConfigHandle}, + config::{get as get_tauri_config, ConfigHandle}, flock, }, interface::{AppInterface, Interface, MobileOptions, Options as InterfaceOptions}, - mobile::{write_options, CliOptions, DevChild, DevProcess}, + mobile::{use_network_address_for_dev_url, write_options, CliOptions, DevChild, DevProcess}, ConfigValue, Result, }; use clap::{ArgAction, Parser}; @@ -29,11 +29,7 @@ use cargo_mobile2::{ opts::{NoiseLevel, Profile}, }; -use std::{ - env::set_current_dir, - net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::OnceLock, -}; +use std::{env::set_current_dir, net::IpAddr}; 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"; @@ -82,7 +78,7 @@ pub struct Options { /// 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 + /// it is your responsibility 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` @@ -222,142 +218,10 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> { ) } -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()); - std::env::set_var("TRUNK_SERVE_ADDRESS", 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, + options: Options, mut dev_options: DevOptions, tauri_config: ConfigHandle, device: Option, @@ -372,7 +236,7 @@ fn run_dev( .map(|device| !matches!(device.kind(), DeviceKind::Simulator)) .unwrap_or(false) { - use_network_address_for_dev_url(&tauri_config, &mut options, &mut dev_options)?; + use_network_address_for_dev_url(&tauri_config, &mut dev_options, options.force_ip_prompt)?; } crate::dev::setup(&interface, &mut dev_options, tauri_config.clone())?; diff --git a/crates/tauri-cli/src/mobile/mod.rs b/crates/tauri-cli/src/mobile/mod.rs index fda97d51261f..a9c443a5c9ca 100644 --- a/crates/tauri-cli/src/mobile/mod.rs +++ b/crates/tauri-cli/src/mobile/mod.rs @@ -5,7 +5,7 @@ use crate::{ helpers::{ app_paths::tauri_dir, - config::{Config as TauriConfig, ConfigHandle}, + config::{reload as reload_config, Config as TauriConfig, ConfigHandle}, }, interface::{AppInterface, AppSettings, DevProcess, Interface, Options as InterfaceOptions}, ConfigValue, @@ -32,12 +32,12 @@ use std::{ ffi::OsString, fmt::Write, fs::{read_to_string, write}, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf, process::{exit, ExitStatus}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, OnceLock, }, }; use tokio::runtime::Runtime; @@ -166,6 +166,144 @@ impl Default for CliOptions { } } +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 + }) +} + +struct DevUrlConfig { + no_dev_server_wait: bool, +} + +fn use_network_address_for_dev_url( + config: &ConfigHandle, + dev_options: &mut crate::dev::Options, + force_ip_prompt: bool, +) -> 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 = dev_options + .host + .unwrap_or_else(|| *local_ip_address(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 dev_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()); + + dev_options + .config + .replace(crate::ConfigValue(serde_json::json!({ + "build": build + }))); + } + reload_config(dev_options.config.as_ref().map(|c| &c.0))?; + + Some(ip) + } else { + None + } + } else if !dev_options.no_dev_server { + let ip = dev_options + .host + .unwrap_or_else(|| *local_ip_address(force_ip_prompt)); + dev_options.host.replace(ip); + Some(ip) + } else { + None + }; + + let mut dev_url_config = DevUrlConfig { + no_dev_server_wait: false, + }; + + if let Some(ip) = ip { + std::env::set_var("TAURI_DEV_HOST", ip.to_string()); + std::env::set_var("TRUNK_SERVE_ADDRESS", ip.to_string()); + if ip.is_ipv6() { + // in this case we can't ping the server for some reason + dev_url_config.no_dev_server_wait = true; + } + } + + Ok(dev_url_config) +} + fn env_vars() -> HashMap { let mut vars = HashMap::new(); vars.insert("RUST_LOG_STYLE".into(), "always".into());