Browse Source

feat(cli): enhance Android dev port forwarding, use host IP for android devices, closes #11137 (#11185)

* feat(cli): enhance Android dev port forwarding, closes #11137

this changes the `android dev` port forwarding (that is actually handled by the `android-studio-script` command - triggered by our Gradle plugin) with some enhancements:

- make the whole process more resilient by checking if the port was actually forwarded and rerunning the `adb reverse` command until it tells us the forward is ready
- if the `adb devices` list is empty, retry a few times (waiting a few seconds) to tolerate devices being booted - slows down "raw builds" (Build Project Android Studio menu for instance) that shouldn't happen often anyway - if you're running `android dev` you're usually running the app on a device instead of simply testing builds

* use host IP to run on android physical device
Lucas Fernandes Nogueira 10 months ago
parent
commit
a08e6ffa6f

+ 6 - 0
.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.

+ 113 - 37
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::<Vec<_>>().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::<Vec<_>>().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<std::process::Output> {
+  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()
 }

+ 31 - 2
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<String>,
+  /// 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<Option<IpAddr>>,
   /// 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 {

+ 6 - 142
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<IpAddr> = OnceLock::new();
-  LOCAL_IP.get_or_init(|| {
-    let prompt_for_ip = || {
-      let addresses: Vec<IpAddr> = 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<Device>,
@@ -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())?;

+ 141 - 3
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<IpAddr> = OnceLock::new();
+  LOCAL_IP.get_or_init(|| {
+    let prompt_for_ip = || {
+      let addresses: Vec<IpAddr> = 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<DevUrlConfig> {
+  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<String, OsString> {
   let mut vars = HashMap::new();
   vars.insert("RUST_LOG_STYLE".into(), "always".into());